front end fixes

This commit is contained in:
Mark Gregory
2026-07-27 07:37:11 +01:00
parent 0be7be48e7
commit 108f95dbfa
21 changed files with 687 additions and 423 deletions
@@ -1,9 +1,9 @@
@extends('stylist-theme-saasland::layouts.app')
@php($title = 'Blog')
@php($description = 'Ideas, guides and updates from our team.')
@php($eyebrow = 'Insights')
@php($breadcrumbs = ['Blog' => null])
@section('content')
<x-stylist-theme-saasland::layouts.app
title="Blog"
description="Ideas, guides and updates from our team."
eyebrow="Insights"
:breadcrumbs="['Blog' => null]"
>
<section class="content-section">
<div class="wrap">
<div class="content-grid">
@@ -24,4 +24,4 @@
<div class="theme-pagination">{{ $records->links() }}</div>
</div>
</section>
@endsection
</x-stylist-theme-saasland::layouts.app>
@@ -1,9 +1,9 @@
@extends('stylist-theme-saasland::layouts.app')
@php($title = $record->title)
@php($description = $record->excerpt)
@php($eyebrow = 'Blog')
@php($breadcrumbs = ['Blog' => route('blog.public.index'), $record->title => null])
@section('content')
<x-stylist-theme-saasland::layouts.app
:title="$record->title"
:description="$record->excerpt"
eyebrow="Blog"
:breadcrumbs="['Blog' => route('blog.public.index'), $record->title => null]"
>
<section class="content-section">
<div class="wrap content-layout">
<article class="content-card prose-content">
@@ -16,4 +16,4 @@
</article>
</div>
</section>
@endsection
</x-stylist-theme-saasland::layouts.app>
@@ -1,9 +1,9 @@
@extends('stylist-theme-saasland::layouts.app')
@php($title = 'News')
@php($description = 'The latest announcements, releases and company updates.')
@php($eyebrow = 'Updates')
@php($breadcrumbs = ['News' => null])
@section('content')
<x-stylist-theme-saasland::layouts.app
title="News"
description="The latest announcements, releases and company updates."
eyebrow="Updates"
:breadcrumbs="['News' => null]"
>
<section class="content-section">
<div class="wrap">
<div class="content-grid">
@@ -24,4 +24,4 @@
<div class="theme-pagination">{{ $records->links() }}</div>
</div>
</section>
@endsection
</x-stylist-theme-saasland::layouts.app>
@@ -1,9 +1,9 @@
@extends('stylist-theme-saasland::layouts.app')
@php($title = $record->title)
@php($description = $record->excerpt)
@php($eyebrow = 'News')
@php($breadcrumbs = ['News' => route('news.public.index'), $record->title => null])
@section('content')
<x-stylist-theme-saasland::layouts.app
:title="$record->title"
:description="$record->excerpt"
eyebrow="News"
:breadcrumbs="['News' => route('news.public.index'), $record->title => null]"
>
<section class="content-section">
<div class="wrap content-layout">
<article class="content-card prose-content">
@@ -16,4 +16,4 @@
</article>
</div>
</section>
@endsection
</x-stylist-theme-saasland::layouts.app>
@@ -32,6 +32,17 @@ final class NewsletterController
return back()->with('status', 'Campaign created.');
}
public function publicSubscribe(Request $r): RedirectResponse
{
$data = $r->validate(['email' => ['required', 'email', 'max:255'], 'name' => ['nullable', 'string', 'max:255']]);
Subscriber::query()->updateOrCreate(
['email' => strtolower($data['email'])],
['name' => $data['name'] ?? null, 'status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()]
);
return back()->with('status', 'Thank you for subscribing.');
}
public function destroySubscriber(Subscriber $subscriber): RedirectResponse
{
$subscriber->delete();
+2
View File
@@ -9,3 +9,5 @@ Route::middleware(['auth', 'verified'])->prefix('administration/newsletter')->na
Route::delete('/subscribers/{subscriber}', [NewsletterController::class, 'destroySubscriber'])->name('subscribers.destroy');
Route::post('/campaigns', [NewsletterController::class, 'storeCampaign'])->name('campaigns.store');
});
Route::post('/newsletter/subscribe', [NewsletterController::class, 'publicSubscribe'])->middleware('throttle:10,1')->name('newsletter.public.subscribe');
@@ -1,9 +1,9 @@
@extends('stylist-theme-saasland::layouts.app')
@php($title = $page->title)
@php($description = $page->summary)
@php($eyebrow = 'Page')
@php($breadcrumbs = [$page->title => null])
@section('content')
<x-stylist-theme-saasland::layouts.app
:title="$page->title"
:description="$page->summary"
eyebrow="Page"
:breadcrumbs="[$page->title => null]"
>
<section class="content-section">
<div class="wrap content-layout">
<article class="content-card prose-content">
@@ -15,4 +15,4 @@
</article>
</div>
</section>
@endsection
</x-stylist-theme-saasland::layouts.app>
@@ -0,0 +1,34 @@
<?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::table('products', function (Blueprint $table): void {
$table->string('product_type')->default('product')->after('name');
$table->text('description')->nullable()->after('product_type');
$table->decimal('price', 12, 2)->nullable()->after('description');
$table->string('currency', 3)->default('GBP')->after('price');
$table->string('billing_period')->nullable()->after('currency');
$table->json('features')->nullable()->after('billing_period');
$table->string('cta_label')->nullable()->after('features');
$table->string('cta_url')->nullable()->after('cta_label');
$table->boolean('is_featured')->default(false)->after('cta_url');
$table->unsignedInteger('sort_order')->default(0)->after('is_featured');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table): void {
$table->dropColumn([
'product_type', 'description', 'price', 'currency', 'billing_period',
'features', 'cta_label', 'cta_url', 'is_featured', 'sort_order',
]);
});
}
};
@@ -11,8 +11,35 @@ final class StoreProductRequest extends FormRequest
return $this->user() !== null;
}
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active'),
'is_featured' => $this->boolean('is_featured'),
'features' => collect(preg_split('/\r\n|\r|\n/', (string) $this->input('features')))
->map(fn (string $feature) => trim($feature))
->filter()
->values()
->all(),
]);
}
public function rules(): array
{
return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']];
return [
'name' => ['required', 'string', 'max:255'],
'product_type' => ['required', 'in:product,plan'],
'description' => ['nullable', 'string'],
'price' => ['nullable', 'numeric', 'min:0'],
'currency' => ['required', 'string', 'size:3'],
'billing_period' => ['nullable', 'string', 'max:40'],
'features' => ['array'],
'features.*' => ['string', 'max:255'],
'cta_label' => ['nullable', 'string', 'max:80'],
'cta_url' => ['nullable', 'string', 'max:2048'],
'is_featured' => ['boolean'],
'sort_order' => ['integer', 'min:0'],
'is_active' => ['boolean'],
];
}
}
+7 -1
View File
@@ -10,6 +10,12 @@ final class Product extends Model
protected function casts(): array
{
return ['is_active' => 'boolean'];
return [
'price' => 'decimal:2',
'features' => 'array',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'sort_order' => 'integer',
];
}
}
@@ -1 +1,25 @@
<form method="POST">@csrf<flux:input name="name" :label="__('Name')" required /><flux:button type="submit" variant="primary">{{ __('Save Product') }}</flux:button></form>
<form method="POST" class="space-y-5">
@csrf
<div class="grid gap-5 md:grid-cols-2">
<flux:input name="name" :label="__('Name')" required />
<flux:select name="product_type" :label="__('Type')">
<option value="product">Product</option>
<option value="plan">Pricing plan</option>
</flux:select>
<flux:input name="price" type="number" step="0.01" min="0" :label="__('Price')" />
<flux:input name="currency" value="GBP" maxlength="3" :label="__('Currency')" />
<flux:input name="billing_period" value="month" :label="__('Billing period')" />
<flux:input name="sort_order" type="number" min="0" value="0" :label="__('Sort order')" />
</div>
<flux:textarea name="description" :label="__('Description')" rows="3" />
<flux:textarea name="features" :label="__('Features — one per line')" rows="6" />
<div class="grid gap-5 md:grid-cols-2">
<flux:input name="cta_label" :label="__('Button label')" />
<flux:input name="cta_url" :label="__('Button URL')" />
</div>
<div class="flex flex-wrap gap-6">
<flux:checkbox name="is_active" value="1" checked :label="__('Active')" />
<flux:checkbox name="is_featured" value="1" :label="__('Featured / most popular')" />
</div>
<flux:button type="submit" variant="primary">{{ __('Save Product') }}</flux:button>
</form>
@@ -1,3 +1,26 @@
<x-layouts.app :title="__('Product')">
<div class="space-y-6"><h1 class="text-2xl font-semibold">Product</h1>@foreach ($records as $record)<div>{{ $record->name }}</div>@endforeach{{ $records->links() }}</div>
<x-layouts.app :title="__('Products & Pricing')">
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold">Products & Pricing</h1>
<p class="mt-1 text-sm text-zinc-500">Manage catalogue products and public-facing pricing plans from one place.</p>
</div>
<section class="rounded-2xl border border-zinc-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-zinc-950/70">
@include('products::admin.products.form')
</section>
<div class="grid gap-4 lg:grid-cols-3">
@forelse ($records as $record)
<article class="rounded-2xl border border-zinc-200/70 bg-white/80 p-5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-zinc-950/70">
<div class="flex items-start justify-between gap-4">
<div><h2 class="font-semibold">{{ $record->name }}</h2><p class="text-sm text-zinc-500">{{ ucfirst($record->product_type ?? 'product') }}</p></div>
@if($record->is_featured)<span class="rounded-full bg-orange-500/10 px-2.5 py-1 text-xs font-semibold text-orange-600">Featured</span>@endif
</div>
@if($record->price !== null)<p class="mt-4 text-2xl font-bold">{{ $record->currency }} {{ $record->price }} <small class="text-sm font-normal">/{{ $record->billing_period }}</small></p>@else<p class="mt-4 text-2xl font-bold">Custom</p>@endif
<p class="mt-2 text-sm text-zinc-500">{{ $record->description }}</p>
</article>
@empty
<p class="text-sm text-zinc-500">No products or plans have been created.</p>
@endforelse
</div>
{{ $records->links() }}
</div>
</x-layouts.app>
@@ -15,6 +15,7 @@ use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Html\ThemeSeoBuilder;
use Modules\Stylist\Support\FrontendNavigation;
use Modules\Stylist\Support\ModuleInfo;
use Modules\Stylist\Services\FrontendContent;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
@@ -71,6 +72,7 @@ final class StylistServiceProvider extends ServiceProvider
$theme = $this->app->make(Loader::class)->find('saasland');
$settings = $this->app->make(ThemeSettings::class)->get($theme);
$navigation = $this->app->make(FrontendNavigation::class);
$homepage = $this->app->make(FrontendContent::class)->homepage($settings);
$view->with([
'siteTitle' => filled($settings['site_title'] ?? null) ? $settings['site_title'] : config('app.name', 'Saasland'),
@@ -81,6 +83,8 @@ final class StylistServiceProvider extends ServiceProvider
'linkedinUrl' => $settings['linkedin_url'] ?? null,
'primaryNavigation' => $navigation->primary(),
'footerNavigation' => $navigation->footer(),
'themeSettings' => $settings,
...$homepage,
]);
});
}
@@ -0,0 +1,99 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Blog\Models\Blog;
use Modules\Faq\Models\Faq;
use Modules\News\Models\News;
use Modules\Products\Models\Product;
use Modules\Team\Models\TeamMember;
use Modules\Testimonials\Models\Testimonial;
use Throwable;
final class FrontendContent
{
/** @return array<string, mixed> */
public function homepage(array $settings): array
{
return [
'pricingPlans' => $this->enabled($settings, 'show_pricing', true) ? $this->pricingPlans() : collect(),
'testimonials' => $this->enabled($settings, 'show_testimonials', true) ? $this->testimonials() : collect(),
'faqs' => $this->enabled($settings, 'show_faq', true) ? $this->faqs() : collect(),
'teamMembers' => $this->enabled($settings, 'show_team', false) ? $this->team() : collect(),
'latestBlogPosts' => $this->enabled($settings, 'show_blog', true) ? $this->blogPosts() : collect(),
'latestNewsArticles' => $this->enabled($settings, 'show_news', true) ? $this->newsArticles() : collect(),
];
}
private function pricingPlans(): Collection
{
return $this->query('products', fn () => Product::query()
->where('is_active', true)
->where('product_type', 'plan')
->orderBy('sort_order')
->orderBy('id')
->limit(6)
->get());
}
private function testimonials(): Collection
{
return $this->query('testimonials', fn () => Testimonial::query()
->where('enabled', true)
->latest('id')
->limit(6)
->get());
}
private function faqs(): Collection
{
return $this->query('faqs', fn () => Faq::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(12)
->get());
}
private function team(): Collection
{
return $this->query('team_members', fn () => TeamMember::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(8)
->get());
}
private function blogPosts(): Collection
{
return $this->query('blog_posts', fn () => Blog::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function newsArticles(): Collection
{
return $this->query('news_articles', fn () => News::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function query(string $table, callable $callback): Collection
{
try {
return Schema::hasTable($table) ? $callback() : collect();
} catch (Throwable) {
return collect();
}
}
private function enabled(array $settings, string $key, bool $default): bool
{
return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
}
}