This commit is contained in:
Mark Gregory
2026-07-24 10:47:01 +01:00
parent dbb9566c49
commit 3f87bea5f9
62 changed files with 402 additions and 5 deletions
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['name' => 'Newsletter'];
@@ -0,0 +1,3 @@
<?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('newsletter_lists',function(Blueprint $t): void{$t->id();$t->string('name');$t->text('description')->nullable();$t->boolean('is_active')->default(true);$t->timestamps();}); Schema::create('newsletter_subscribers',function(Blueprint $t): void{$t->id();$t->string('email')->unique();$t->string('name')->nullable();$t->string('status')->default('pending')->index();$t->string('confirmation_token')->nullable()->unique();$t->timestamp('confirmed_at')->nullable();$t->timestamp('unsubscribed_at')->nullable();$t->json('metadata')->nullable();$t->timestamps();}); Schema::create('newsletter_list_subscriber',function(Blueprint $t): void{$t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete();$t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete();$t->primary(['list_id','subscriber_id']);}); Schema::create('newsletter_campaigns',function(Blueprint $t): void{$t->id();$t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();$t->string('name');$t->string('subject');$t->longText('content')->nullable();$t->string('status')->default('draft')->index();$t->timestamp('scheduled_at')->nullable();$t->timestamp('sent_at')->nullable();$t->unsignedInteger('sent_count')->default(0);$t->unsignedInteger('open_count')->default(0);$t->unsignedInteger('click_count')->default(0);$t->timestamps();}); } public function down(): void { Schema::dropIfExists('newsletter_campaigns');Schema::dropIfExists('newsletter_list_subscriber');Schema::dropIfExists('newsletter_subscribers');Schema::dropIfExists('newsletter_lists'); } };
@@ -0,0 +1,3 @@
<?php
namespace Modules\Newsletter\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Illuminate\Support\Str; use Modules\Newsletter\Models\Campaign; use Modules\Newsletter\Models\MailingList; use Modules\Newsletter\Models\Subscriber;
final class NewsletterController { public function index(): View { return view('newsletter::admin.index',['subscribers'=>Subscriber::latest()->paginate(20),'campaigns'=>Campaign::latest()->limit(10)->get(),'lists'=>MailingList::orderBy('name')->get()]); } public function storeSubscriber(Request $r): RedirectResponse { $d=$r->validate(['email'=>['required','email','unique:newsletter_subscribers,email'],'name'=>['nullable','string','max:255']]); Subscriber::create($d+['status'=>'confirmed','confirmation_token'=>Str::random(48),'confirmed_at'=>now()]); return back()->with('status','Subscriber added.'); } public function storeCampaign(Request $r): RedirectResponse { Campaign::create($r->validate(['list_id'=>['nullable','exists:newsletter_lists,id'],'name'=>['required','string','max:255'],'subject'=>['required','string','max:255'],'content'=>['nullable','string'],'status'=>['required','in:draft,scheduled,sent'],'scheduled_at'=>['nullable','date']])); return back()->with('status','Campaign created.'); } public function destroySubscriber(Subscriber $subscriber): RedirectResponse { $subscriber->delete(); return back()->with('status','Subscriber deleted.'); } }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace Modules\Newsletter\Models; use Illuminate\Database\Eloquent\Model;
final class Campaign extends Model { protected $table='newsletter_campaigns'; protected $fillable=['list_id','name','subject','content','status','scheduled_at','sent_at','sent_count','open_count','click_count']; protected function casts(): array { return ['scheduled_at'=>'datetime','sent_at'=>'datetime']; } }
@@ -0,0 +1,3 @@
<?php
namespace Modules\Newsletter\Models; use Illuminate\Database\Eloquent\Model;
final class MailingList extends Model { protected $table='newsletter_lists'; protected $fillable=['name','description','is_active']; protected function casts(): array { return ['is_active'=>'boolean']; } }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace Modules\Newsletter\Models; use Illuminate\Database\Eloquent\Model;
final class Subscriber extends Model { protected $table='newsletter_subscribers'; protected $fillable=['email','name','status','confirmation_token','confirmed_at','unsubscribed_at','metadata']; protected function casts(): array { return ['confirmed_at'=>'datetime','unsubscribed_at'=>'datetime','metadata'=>'array']; } }
@@ -0,0 +1,16 @@
<?php
namespace Modules\Newsletter\Providers;
use Illuminate\Support\ServiceProvider;
final class NewsletterServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Newsletter', 'Resources/views'), 'newsletter');
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Newsletter\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')->group(module_path('Newsletter', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('Newsletter')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">Newsletter</h1><p class="text-sm text-zinc-500">Manage subscribers and email campaigns.</p></div>@if(session('status'))<div class="rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="grid gap-6 lg:grid-cols-2"><form method="POST" action="{{ route('administration.newsletter.subscribers.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Add subscriber</h2><flux:input name="email" type="email" label="Email" required/><flux:input name="name" label="Name"/><flux:button type="submit" variant="primary">Add subscriber</flux:button></form><form method="POST" action="{{ route('administration.newsletter.campaigns.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Create campaign</h2><flux:input name="name" label="Campaign name" required/><flux:input name="subject" label="Subject" required/><flux:select name="status" label="Status"><option value="draft">Draft</option><option value="scheduled">Scheduled</option></flux:select><flux:textarea name="content" label="Content"/><flux:button type="submit" variant="primary">Create campaign</flux:button></form></div><div class="overflow-hidden rounded-xl border"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Subscriber</th><th class="p-3 text-left">Status</th><th class="p-3 text-left">Confirmed</th><th class="p-3"></th></tr></thead><tbody>@forelse($subscribers as $subscriber)<tr class="border-t"><td class="p-3"><div class="font-medium">{{ $subscriber->email }}</div><div class="text-zinc-500">{{ $subscriber->name }}</div></td><td class="p-3">{{ ucfirst($subscriber->status) }}</td><td class="p-3">{{ optional($subscriber->confirmed_at)->format('j M Y') ?: '—' }}</td><td class="p-3 text-right"><form method="POST" action="{{ route('administration.newsletter.subscribers.destroy',$subscriber) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="4" class="p-6 text-center text-zinc-500">No subscribers yet.</td></tr>@endforelse</tbody></table></div>{{ $subscribers->links() }}</div></x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Newsletter\Http\Controllers\NewsletterController;
Route::middleware(['auth','verified'])->prefix('administration/newsletter')->name('administration.newsletter.')->group(function(): void { Route::get('/',[NewsletterController::class,'index'])->name('index'); Route::post('/subscribers',[NewsletterController::class,'storeSubscriber'])->name('subscribers.store'); Route::delete('/subscribers/{subscriber}',[NewsletterController::class,'destroySubscriber'])->name('subscribers.destroy'); Route::post('/campaigns',[NewsletterController::class,'storeCampaign'])->name('campaigns.store'); });
@@ -0,0 +1,10 @@
<?php
namespace Modules\Newsletter\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Newsletter', config('newsletter.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Newsletter",
"alias": "newsletter",
"version": "1.0.0",
"description": "Subscriber lists and email campaign management.",
"keywords": ["business", "newsletter"],
"priority": 41,
"providers": ["Modules\\Newsletter\\Providers\\NewsletterServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}