Compare commits

...

1 Commits

Author SHA1 Message Date
Yeltsin Batiancila
4b86e5fd22 feat: add EmailTemplate resource with CRUD functionality and email template seeder 2025-10-24 22:54:06 +08:00
13 changed files with 725 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EmailTemplateResource\Pages\CreateEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Pages\EditEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Pages\ListEmailTemplates;
use App\Filament\Resources\EmailTemplateResource\Pages\ViewEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateForm;
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateInfolist;
use App\Filament\Resources\EmailTemplateResource\Tables\EmailTemplatesTable;
use App\Models\EmailTemplate;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class EmailTemplateResource extends Resource
{
protected static ?string $model = EmailTemplate::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?int $navigationSort = 10;
protected static ?string $navigationLabel = 'Email Templates';
protected static ?string $modelLabel = 'Email Template';
protected static ?string $pluralModelLabel = 'Email Templates';
public static function getNavigationBadge(): ?string
{
// Count inactive templates
return static::getModel()::where('is_active', false)->count() ?: null;
}
public static function form(Schema $schema): Schema
{
return EmailTemplateForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return EmailTemplateInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return EmailTemplatesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListEmailTemplates::route('/'),
'create' => CreateEmailTemplate::route('/create'),
'view' => ViewEmailTemplate::route('/{record}'),
'edit' => EditEmailTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEmailTemplate extends CreateRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditEmailTemplate extends EditRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListEmailTemplates extends ListRecords
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewEmailTemplate extends ViewRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
use App\Models\EmailTemplate;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class EmailTemplateForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make('Template Details')
->schema([
TextInput::make('key')
->label('Template Key')
->required()
->unique(ignoreRecord: true)
->regex('/^[a-z0-9_-]+$/')
->helperText('Lowercase alphanumeric characters, hyphens and underscores only')
->disabled(fn ($context) => $context === 'edit')
->dehydrated(fn ($context) => $context === 'create')
->columnSpanFull(),
TextInput::make('name')
->label('Template Name')
->required()
->maxLength(255)
->columnSpanFull(),
TextInput::make('subject')
->label('Email Subject')
->required()
->maxLength(255)
->columnSpanFull(),
Textarea::make('description')
->label('Description')
->helperText('Describe when this template is used')
->rows(3)
->columnSpanFull(),
TextInput::make('available_variables')
->label('Available Variables')
->helperText('Comma-separated list (e.g., verification_url, email, logo_url)')
->afterStateHydrated(function (TextInput $component, $state) {
if (is_array($state)) {
$component->state(implode(', ', $state));
}
})
->dehydrateStateUsing(function ($state) {
if (empty($state)) {
return [];
}
return array_map('trim', explode(',', $state));
})
->columnSpanFull(),
Checkbox::make('is_active')
->label('Template is Active')
->default(true)
->inline(false),
])
->columns(2),
Section::make('Email Content')
->schema([
Textarea::make('body_html')
->label('HTML Body')
->required()
->rows(25)
->helperText('Use {{ $variable }} syntax for dynamic content')
->columnSpanFull()
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
Textarea::make('body_text')
->label('Plain Text Body (Optional)')
->rows(15)
->helperText('Plain text fallback for email clients that don\'t support HTML')
->columnSpanFull()
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
]),
Section::make('Metadata')
->schema([
Placeholder::make('created_at')
->label('Created At')
->content(fn (?EmailTemplate $record): string => $record?->created_at?->diffForHumans() ?? '-'),
Placeholder::make('updated_at')
->label('Last Updated')
->content(fn (?EmailTemplate $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
])
->columns(2)
->hidden(fn ($context) => $context === 'create'),
]);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class EmailTemplateInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('name')
->label('Template Name')
->columnSpan(1),
TextEntry::make('key')
->label('Template Key')
->badge()
->copyable()
->copyMessage('Key copied!')
->copyMessageDuration(1500)
->columnSpan(1),
TextEntry::make('subject')
->label('Email Subject')
->columnSpan(2),
TextEntry::make('description')
->label('Description')
->columnSpan(2)
->placeholder('No description provided'),
TextEntry::make('available_variables')
->label('Available Variables')
->badge()
->separator(',')
->columnSpan(2)
->placeholder('No variables defined'),
IconEntry::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->columnSpan(1),
TextEntry::make('created_at')
->label('Created')
->dateTime()
->since()
->columnSpan(1),
TextEntry::make('updated_at')
->label('Last Updated')
->dateTime()
->since()
->columnSpan(1),
ViewEntry::make('preview')
->label('HTML Preview')
->viewData(fn ($record) => [
'html' => $record->body_html,
])
->view('filament.email-template-preview')
->columnSpan(2),
TextEntry::make('body_html')
->label('HTML Source')
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">' . htmlspecialchars($state) . '</pre>'))
->columnSpan(2),
TextEntry::make('body_text')
->label('Plain Text Version')
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">' . htmlspecialchars($state ?: 'No plain text version') . '</pre>'))
->columnSpan(2)
->hidden(fn ($record) => empty($record->body_text)),
])
->columns(2);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class EmailTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Template Name')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('key')
->label('Key')
->searchable()
->sortable()
->fontFamily('mono')
->size('sm')
->copyable()
->copyMessage('Key copied!')
->copyMessageDuration(1500),
TextColumn::make('subject')
->label('Subject')
->searchable()
->limit(50)
->wrap(),
IconColumn::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->sortable(),
TextColumn::make('updated_at')
->label('Last Updated')
->dateTime()
->sortable()
->since()
->size('sm'),
])
->defaultSort('name')
->filters([
SelectFilter::make('is_active')
->label('Status')
->options([
true => 'Active',
false => 'Inactive',
]),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmailTemplate extends Model
{
use HasFactory;
protected $fillable = [
'key',
'name',
'subject',
'body_html',
'body_text',
'available_variables',
'description',
'is_active',
];
protected $casts = [
'available_variables' => 'array',
'is_active' => 'boolean',
];
/**
* Get a template by its key
*/
public static function getByKey(string $key): ?self
{
return self::where('key', $key)
->where('is_active', true)
->first();
}
/**
* Render the template with provided variables
*/
public function render(array $variables = []): string
{
$html = $this->body_html;
foreach ($variables as $key => $value) {
$html = str_replace('{{ $' . $key . ' }}', $value, $html);
$html = str_replace('{{$' . $key . '}}', $value, $html);
}
return $html;
}
/**
* Render the subject with provided variables
*/
public function renderSubject(array $variables = []): string
{
$subject = $this->subject;
foreach ($variables as $key => $value) {
$subject = str_replace('{{ $' . $key . ' }}', $value, $subject);
$subject = str_replace('{{$' . $key . '}}', $value, $subject);
}
return $subject;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\EmailTemplate;
use App\Models\EmailVerification;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;
@@ -72,9 +73,29 @@ class EmailVerificationService
*/
public function sendVerificationEmail(string $email, string $token): void
{
$verificationUrl = route('buyer.register.verify', ['token' => $token]);
$verificationUrl = route('buyer.register.complete', ['token' => $token]);
Mail::to($email)->send(new \App\Mail\RegistrationVerificationMail($verificationUrl, $email));
// Try to use database template first, fall back to mailable if not found
$template = EmailTemplate::getByKey('registration_verification');
if ($template) {
$variables = [
'verification_url' => $verificationUrl,
'email' => $email,
'logo_url' => asset('assets/images/canna_white.png'),
'account_name' => 'Cannabrands',
'support_email' => 'hello@cannabrands.com',
];
Mail::send([], [], function ($message) use ($template, $email, $variables) {
$message->to($email)
->subject($template->renderSubject($variables))
->html($template->render($variables));
});
} else {
// Fallback to original mailable
Mail::to($email)->send(new \App\Mail\RegistrationVerificationMail($verificationUrl, $email));
}
}
/**

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('email_templates', function (Blueprint $table) {
$table->id();
$table->string('key')->unique()->comment('Unique identifier for the template');
$table->string('name')->comment('Human-readable name');
$table->string('subject');
$table->text('body_html')->comment('HTML version of email');
$table->text('body_text')->nullable()->comment('Plain text version');
$table->json('available_variables')->nullable()->comment('List of variables that can be used in this template');
$table->text('description')->nullable()->comment('Description of when this template is used');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('email_templates');
}
};

View File

@@ -0,0 +1,181 @@
<?php
namespace Database\Seeders;
use App\Models\EmailTemplate;
use Illuminate\Database\Seeder;
class EmailTemplateSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Registration Verification Email
EmailTemplate::updateOrCreate(
['key' => 'registration_verification'],
[
'name' => 'Registration Verification',
'subject' => 'Complete Your Cannabrands Registration',
'body_html' => $this->getRegistrationVerificationHtml(),
'body_text' => $this->getRegistrationVerificationText(),
'available_variables' => [
'verification_url',
'email',
'logo_url',
'account_name',
'support_email',
],
'description' => 'Sent to users when they start the registration process to verify their email address.',
'is_active' => true,
]
);
$this->command->info('Email templates seeded successfully!');
}
private function getRegistrationVerificationHtml(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
background-color: #f4f5f7;
font-family: Arial, sans-serif;
color: #172B4D;
}
.main {
max-width: 600px;
margin: auto;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #014847 0%, #014342 100%);
padding: 30px;
text-align: center;
}
.header img {
height: 32px;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
margin: 0;
color: #fff;
font-weight: normal;
}
.content {
padding: 30px;
font-size: 16px;
}
.content p {
margin: 20px 0 10px;
}
.highlight-box {
background: #f1f5f9;
padding: 12px 16px;
border-radius: 6px;
margin-top: 20px;
}
.button-container {
text-align: center;
margin-top: 30px;
}
.button {
display: inline-block;
background-color: #015b59;
color: #ffffff;
text-decoration: none;
padding: 14px 28px;
font-weight: bold;
border-radius: 5px;
margin-top: 20px;
}
.button:hover {
background-color: #014847;
}
.footer {
padding: 30px;
font-size: 14px;
text-align: left;
color: #6b7280;
}
.footer a {
color: #015b59;
text-decoration: none;
}
.expiry-note {
font-size: 14px;
color: #6b7280;
text-align: center;
margin-top: 15px;
}
</style>
</head>
<body>
<div class="main">
<div class="header">
<img src="{{ $logo_url }}" alt="{{ $account_name }} Logo" />
<h1>Complete Your Registration</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>Thank you for starting your registration with {{ $account_name }}! To complete your account setup, please click the button below to verify your email address and fill out your profile.</p>
<div class="highlight-box">
<strong>Email:</strong> {{ $email }}
</div>
<div class="button-container">
<a href="{{ $verification_url }}" class="button">Complete Registration</a>
</div>
<p class="expiry-note">
⏱️ This link will expire in 24 hours for security reasons.
</p>
<p>If you didn't request this registration, you can safely ignore this email.</p>
</div>
<div class="footer">
<p>Need help?</p>
<p>Contact us at <a href="mailto:{{ $support_email }}">{{ $support_email }}</a>.</p>
<p> The {{ $account_name }} Team</p>
</div>
</div>
</body>
</html>
HTML;
}
private function getRegistrationVerificationText(): string
{
return <<<'TEXT'
Complete Your Registration
Hello,
Thank you for starting your registration with {{ $account_name }}! To complete your account setup, please visit the link below to verify your email address and fill out your profile.
Email: {{ $email }}
Complete your registration here:
{{ $verification_url }}
⏱️ This link will expire in 24 hours for security reasons.
If you didn't request this registration, you can safely ignore this email.
Need help?
Contact us at {{ $support_email }}.
The {{ $account_name }} Team
TEXT;
}
}

View File

@@ -0,0 +1,7 @@
<div class="w-full">
<iframe
srcdoc="{{ $html }}"
class="w-full border-2 border-gray-200 dark:border-gray-700 rounded-lg bg-white"
style="min-height: 600px; width: 100%;"
></iframe>
</div>