diff --git a/.DS_Store b/.DS_Store index c37d51b..e187da8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index 9b4330a..5ee13de 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -16,6 +16,8 @@ use Illuminate\Validation\Rule; class EmailTemplateController extends Controller { + private ?array $scenarioLabelOverridesCache = null; + public function index(Request $request): JsonResponse { $query = Template::with('invoiceTemplate:id,name,scenario') @@ -37,25 +39,68 @@ class EmailTemplateController extends Controller return response()->json(['error' => false, 'data' => $this->format($template)]); } - /** Danh sách scenario hợp lệ (key + label) để render dropdown động ở frontend. */ + /** + * Danh sách scenario để render dropdown động ở frontend — gồm cả scenario + * đã khai báo sẵn (Template::SCENARIOS) lẫn scenario tự tạo (đã dùng + * trong bảng template nhưng chưa có label cứng trong code). + */ public function scenarios(): JsonResponse { - $used = Template::whereNotNull('scenario')->pluck('scenario')->all(); + $used = Template::whereNotNull('scenario')->distinct()->pluck('scenario')->all(); + $labels = $this->scenarioLabelOverrides(); - $data = collect(Template::SCENARIOS)->map(fn ($label, $key) => [ - 'key' => $key, - 'label' => $label, + $known = collect(Template::SCENARIOS)->map(fn ($label, $key) => [ + 'key' => $key, + 'label' => $labels[$key] ?? $label, 'has_template' => in_array($key, $used, true), - ])->values(); + ]); - return response()->json(['error' => false, 'data' => $data]); + $custom = collect($used) + ->reject(fn ($key) => array_key_exists($key, Template::SCENARIOS)) + ->map(fn ($key) => ['key' => $key, 'label' => $labels[$key] ?? $key, 'has_template' => true]); + + return response()->json(['error' => false, 'data' => $known->concat($custom)->values()]); + } + + /** + * Đặt/đổi tên hiển thị (label) cho một scenario — cho cả scenario tự tạo + * lẫn scenario khai báo sẵn trong Template::SCENARIOS. Lưu trong Setting + * (key=scenario_labels) vì scenario là khái niệm chung, không thuộc riêng + * một Template row nào. + */ + public function updateScenarioLabel(Request $request, string $key): JsonResponse + { + $data = $request->validate([ + 'label' => 'required|string|max:255', + ]); + + $setting = Setting::firstOrNew(['key' => 'scenario_labels']); + $labels = $setting->value ? json_decode($setting->value, true) : []; + $labels[$key] = trim($data['label']); + $setting->value = json_encode($labels, JSON_UNESCAPED_UNICODE); + $setting->save(); + + return response()->json(['error' => false, 'message' => 'Đã đổi tên tình huống.', 'data' => ['key' => $key, 'label' => $labels[$key]]]); + } + + private function scenarioLabelOverrides(): array + { + if ($this->scenarioLabelOverridesCache !== null) { + return $this->scenarioLabelOverridesCache; + } + + $setting = Setting::where('key', 'scenario_labels')->first(); + return $this->scenarioLabelOverridesCache = $setting && $setting->value + ? (json_decode($setting->value, true) ?: []) + : []; } public function store(Request $request): JsonResponse { $data = $request->validate([ 'name' => 'nullable|string|max:255', - 'scenario' => ['nullable', 'string', Rule::in(array_keys(Template::SCENARIOS))], + 'scenario' => ['nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/'], + 'key' => ['nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:template,key'], 'format' => 'nullable|in:html,json', 'invoice_template_id' => 'nullable|integer|exists:template,id', 'subject' => 'nullable|string|max:255', @@ -76,6 +121,7 @@ class EmailTemplateController extends Controller 'type_id' => 0, 'name' => $data['name'] ?? null, 'scenario' => $data['scenario'] ?? null, + 'key' => $data['key'] ?? null, 'format' => $data['format'] ?? 'html', 'invoice_template_id' => $data['invoice_template_id'] ?? null, 'subject' => $data['subject'] ?? null, @@ -96,7 +142,8 @@ class EmailTemplateController extends Controller $data = $request->validate([ 'name' => 'sometimes|nullable|string|max:255', - 'scenario' => ['sometimes', 'nullable', 'string', Rule::in(array_keys(Template::SCENARIOS))], + 'scenario' => ['sometimes', 'nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/'], + 'key' => ['sometimes', 'nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/', Rule::unique('template', 'key')->ignore($template->id)], 'format' => 'sometimes|nullable|in:html,json', 'invoice_template_id' => 'sometimes|nullable|integer|exists:template,id', 'subject' => 'sometimes|nullable|string|max:255', @@ -293,7 +340,10 @@ class EmailTemplateController extends Controller 'type_id' => $t->type_id, 'name' => $t->name, 'scenario' => $t->scenario, - 'scenario_label' => Template::SCENARIOS[$t->scenario] ?? null, + 'key' => $t->key, + 'scenario_label' => $t->scenario + ? ($this->scenarioLabelOverrides()[$t->scenario] ?? Template::SCENARIOS[$t->scenario] ?? $t->scenario) + : null, 'format' => $t->format ?? 'html', 'invoice_template_id' => $t->invoice_template_id, 'invoice_template_name' => $t->invoiceTemplate?->name, diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index d0863f5..6af483e 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -106,10 +106,7 @@ final class MailCampaignController extends Controller private function renderPreview(string $subject, string $message): string { - return view('emails.customer.template-camp', [ - 'content' => $message, - 'emailSubject' => $subject, - ])->render(); + return Template::renderWithLayout($subject, $message); } public function store(Request $request): JsonResponse diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php index 773dca7..37e0f7a 100644 --- a/app/Mail/Customer/ManualCustomerEmail.php +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Models\Template; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -26,8 +27,7 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue public function content(): Content { return new Content( - view: 'emails.customer.template-camp', - with: ['content' => $this->htmlContent, 'emailSubject' => $this->emailSubject], + html: Template::renderWithLayout($this->emailSubject, $this->htmlContent), ); } diff --git a/app/Models/Template.php b/app/Models/Template.php index cf64960..ac97d1f 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -17,6 +17,7 @@ class Template extends Model protected $fillable = [ 'type_id', 'scenario', + 'key', 'format', 'invoice_template_id', 'name', @@ -44,6 +45,9 @@ class Template extends Model if ($this->scenario) { Cache::forget(self::CACHE_PREFIX . $this->scenario); } + if ($this->key) { + Cache::forget(self::CACHE_PREFIX . $this->key); + } } /** Danh sách variable hợp lệ khai báo trong metadata.variables, key theo variable key. */ @@ -59,6 +63,7 @@ class Template extends Model } public const SCENARIOS = [ + 'layout' => 'Bố cục Header/Footer (mặc định)', 'vm_credentials' => 'Khởi tạo dịch vụ', 'renewal_reminder' => 'Nhắc gia hạn', 'news_notification' => 'Thông báo tin tức', @@ -102,6 +107,30 @@ class Template extends Model return $this->belongsTo(self::class, 'invoice_template_id'); } + /** + * Wrap $contentHtml with the admin-editable "layout" template (scenario=layout), + * replacing its {{$content}} placeholder. Falls back to the static blade + * template-camp wrapper when no active layout template is configured. + */ + public static function renderWithLayout(?string $subject, string $contentHtml): string + { + $layout = self::where('scenario', 'layout')->where('status', 1)->first(); + + if (!$layout || !$layout->message) { + return view('emails.customer.template-camp', [ + 'content' => $contentHtml, + 'emailSubject' => $subject, + ])->render(); + } + + $body = str_replace('{{$content}}', $contentHtml, $layout->message); + + return view('emails.customer.template-envelope', [ + 'body' => $body, + 'emailSubject' => $subject, + ])->render(); + } + public function type() { return $this->belongsTo(\App\Models\Type::class); diff --git a/app/Services/Email/EmailTemplateService.php b/app/Services/Email/EmailTemplateService.php index 2319f01..ec5cd73 100644 --- a/app/Services/Email/EmailTemplateService.php +++ b/app/Services/Email/EmailTemplateService.php @@ -34,12 +34,18 @@ class EmailTemplateService return $this->renderer->render($this->loadTemplate($scenario), $data); } - public function loadTemplate(string $scenario): Template + /** + * Tìm template theo `key` (định danh riêng, ưu tiên) hoặc `scenario` + * (tương thích ngược với các lời gọi cũ truyền scenario làm định danh). + */ + public function loadTemplate(string $key): Template { return Cache::remember( - Template::CACHE_PREFIX . $scenario, + Template::CACHE_PREFIX . $key, 3600, - fn () => Template::where('scenario', $scenario)->where('status', 1)->firstOrFail(), + fn () => Template::where('status', 1) + ->where(fn ($q) => $q->where('key', $key)->orWhere('scenario', $key)) + ->firstOrFail(), ); } } diff --git a/database/migrations/2026_09_10_000001_seed_default_layout_email_template.php b/database/migrations/2026_09_10_000001_seed_default_layout_email_template.php new file mode 100644 index 0000000..29304f8 --- /dev/null +++ b/database/migrations/2026_09_10_000001_seed_default_layout_email_template.php @@ -0,0 +1,141 @@ + Email Template like any other template. + */ + public function up(): void + { + if (Template::where('scenario', 'layout')->exists()) { + return; + } + + Template::create([ + 'type_id' => 0, + 'name' => 'Bố cục Header/Footer mặc định', + 'scenario' => 'layout', + 'format' => 'html', + 'subject' => null, + 'status' => 1, + 'message' => $this->layoutHtml(), + ]); + } + + public function down(): void + { + Template::where('scenario', 'layout')->delete(); + } + + private function layoutHtml(): string + { + return <<<'HTML' + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + +
+
+ {{$content}} +
+
+HTML; + } +}; diff --git a/database/migrations/2026_09_10_000002_add_key_to_template_table.php b/database/migrations/2026_09_10_000002_add_key_to_template_table.php new file mode 100644 index 0000000..b2d021e --- /dev/null +++ b/database/migrations/2026_09_10_000002_add_key_to_template_table.php @@ -0,0 +1,34 @@ +string('key', 100)->nullable()->unique()->after('scenario') + ->comment('Định danh duy nhất dùng để tra template khi gửi mail (EmailTemplateService::loadTemplate)'); + }); + + // Backfill: mặc định key = scenario cho các template đã có scenario, + // để mọi lời gọi gửi email theo scenario hiện tại tiếp tục hoạt động. + DB::table('template') + ->whereNotNull('scenario') + ->orderBy('id') + ->get(['id', 'scenario']) + ->each(fn ($row) => DB::table('template')->where('id', $row->id)->update(['key' => $row->scenario])); + } + + public function down(): void + { + Schema::table('template', function (Blueprint $table) { + $table->dropColumn('key'); + }); + } +}; diff --git a/resources/views/emails/customer/template-camp.blade.php b/resources/views/emails/customer/template-camp.blade.php index 5edeacc..3edfe20 100644 --- a/resources/views/emails/customer/template-camp.blade.php +++ b/resources/views/emails/customer/template-camp.blade.php @@ -12,94 +12,13 @@ - - - + @include('emails.partials.email-header') - - - + @include('emails.partials.email-footer')
- - - - - - -
- - Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số - -
-
{!! $content !!}
diff --git a/resources/views/emails/customer/template-email.blade.php b/resources/views/emails/customer/template-email.blade.php new file mode 100644 index 0000000..bf8e36c --- /dev/null +++ b/resources/views/emails/customer/template-email.blade.php @@ -0,0 +1,178 @@ +@php + $companyName = $companyName ?? (setting('setting.company') ?: 'Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số'); + $headerBannerUrl = $headerBannerUrl ?? (setting('setting.email_header_banner') ?: 'https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png'); +@endphp + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + Thư gửi từ {{ $companyName }} + +
+
+ + + + + +
+ + + + + + +
+ Đơn hàng mới +
+ +

Có đơn đăng ký tên miền mới 🎉

+

+ Một khách hàng vừa đăng ký tên miền tại {{company_name}}. Chi tiết đơn hàng bên dưới. +

+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Tên miền{{domain_name}}
Mã đơn hàng{{order_num}}
Tổng tiền{{order_total}}
Khách hàng{{customer_name}}
Email{{customer_email}}
Thời gian{{created_at}}
+
+ + + + + + +
+ XEM ĐƠN HÀNG → +
+ +

+ 💡 Đây là tin nhắn tự động, vui lòng không trả lời email này. +

+
 
+
+
diff --git a/resources/views/emails/customer/template-envelope.blade.php b/resources/views/emails/customer/template-envelope.blade.php new file mode 100644 index 0000000..2a3882c --- /dev/null +++ b/resources/views/emails/customer/template-envelope.blade.php @@ -0,0 +1,11 @@ + + + + + + {{ $emailSubject ?? '' }} + + +{!! $body !!} + + diff --git a/resources/views/emails/layouts/notification.blade.php b/resources/views/emails/layouts/notification.blade.php index 013c875..27cf2c9 100644 --- a/resources/views/emails/layouts/notification.blade.php +++ b/resources/views/emails/layouts/notification.blade.php @@ -9,21 +9,7 @@ - - - + @include('emails.partials.email-header', ['companyName' => $companyName]) - - - + @include('emails.partials.email-footer', ['companyName' => $companyName])
- - - - - - -
- - Thư gửi từ {{ $companyName }} - -
-
@if($bannerUrl) @@ -41,74 +27,7 @@ {!! $body !!}
diff --git a/resources/views/emails/partials/email-footer.blade.php b/resources/views/emails/partials/email-footer.blade.php index c0aa5d5..d71caa4 100644 --- a/resources/views/emails/partials/email-footer.blade.php +++ b/resources/views/emails/partials/email-footer.blade.php @@ -1,6 +1,9 @@ @php - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; + $supportEmail = $supportEmail ?? (setting('setting.support') ?: 'support@tgs.com.vn'); + $supportPhone = $supportPhone ?? (setting('setting.hotline') ?: '(028) 7309. 7379'); + $companyName = $companyName ?? (setting('setting.company') ?: 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ'); + $companyAddress = $companyAddress ?? (setting('setting.address') ?: '128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh'); + $companyTax = $companyTax ?? (setting('setting.tax') ?: '0318443116'); @endphp @@ -30,17 +33,17 @@ - CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ + {{ $companyName }} - Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh + Địa chỉ: {{ $companyAddress }} - Tel: {{ $supportPhone }}  -  MST: 0318443116 + Tel: {{ $supportPhone }}  -  MST: {{ $companyTax }} diff --git a/resources/views/emails/partials/email-header.blade.php b/resources/views/emails/partials/email-header.blade.php index dd30e1e..3c064af 100644 --- a/resources/views/emails/partials/email-header.blade.php +++ b/resources/views/emails/partials/email-header.blade.php @@ -1,3 +1,7 @@ +@php + $companyName = $companyName ?? (setting('setting.company') ?: 'Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số'); + $headerBannerUrl = $headerBannerUrl ?? (setting('setting.email_header_banner') ?: 'https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png'); +@endphp @@ -5,7 +9,7 @@ diff --git a/routes/api.php b/routes/api.php index 603fcc1..65b72d4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -618,6 +618,7 @@ Route::prefix('v1') Route::get('/', 'index'); Route::post('/', 'store'); Route::get('scenarios', 'scenarios'); + Route::put('scenarios/{key}/label', 'updateScenarioLabel'); Route::get('email-settings', 'getEmailSettings'); Route::put('email-settings', 'updateEmailSettings'); Route::get('{id}', 'show');
- Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + Thư gửi từ {{ $companyName }}