File: /home/xedaptot/hi.naniguide.com/app/Http/Controllers/Customer/TemplateController.php
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\BuiltInTemplateSetting;
use App\Models\DeliveryServer;
use App\Models\ListSubscriber;
use App\Models\Plan;
use App\Models\PublicTemplate;
use App\Models\PublicTemplateCategory;
use App\Models\Template;
use App\Services\AI\AiTemplateService;
use App\Services\DeliveryServerService;
use App\Services\ZeptoMailApiService;
use App\Services\TemplateService;
use App\Support\EmailFooter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class TemplateController extends Controller
{
public function __construct(
protected TemplateService $templateService,
protected AiTemplateService $aiTemplateService
) {
$this->middleware('customer.access:templates.permissions.can_access_templates')->only([
'index',
'show',
'preview',
'getContent',
'sendTestEmail',
]);
$this->middleware('customer.access:templates.permissions.can_create_templates')->only(['create', 'store', 'createUnlayer', 'storeUnlayer', 'duplicate']);
$this->middleware('customer.access:templates.permissions.can_edit_templates')->only(['edit', 'update', 'editUnlayer', 'updateUnlayer']);
$this->middleware('customer.access:templates.permissions.can_delete_templates')->only(['destroy']);
$this->middleware('customer.access:templates.permissions.can_import_templates')->only(['importGallery', 'importContent', 'importFileContent']);
$this->middleware('customer.access:templates.permissions.can_use_ai_creator')->only(['aiGenerate']);
}
private function getSelectableDeliveryServers(): \Illuminate\Support\Collection
{
$customer = auth('customer')->user();
if (!$customer) {
return collect();
}
$mustAddDelivery = (bool) $customer->groupSetting('servers.permissions.must_add_delivery_server', false);
$canUseSystem = (bool) $customer->groupSetting('servers.permissions.can_use_system_servers', false);
return DeliveryServer::query()
->where('status', 'active')
->where('use_for', true)
->when($mustAddDelivery, function ($q) use ($customer) {
$q->where('customer_id', $customer->id);
}, function ($q) use ($customer, $canUseSystem) {
$q->where(function ($sub) use ($customer, $canUseSystem) {
$sub->where('customer_id', $customer->id);
if ($canUseSystem) {
$sub->orWhereNull('customer_id');
}
});
})
->orderBy('name')
->get();
}
private function authorizeSelectableDeliveryServer(int $deliveryServerId): DeliveryServer
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
$deliveryServer = DeliveryServer::query()->where('status', 'active')->where('use_for', true)->findOrFail($deliveryServerId);
if ((int) $deliveryServer->customer_id === (int) $customer->id) {
return $deliveryServer;
}
$mustAddDelivery = (bool) $customer->groupSetting('servers.permissions.must_add_delivery_server', false);
$canUseSystem = (bool) $customer->groupSetting('servers.permissions.can_use_system_servers', false);
if (!$mustAddDelivery && $canUseSystem && $deliveryServer->customer_id === null && $deliveryServer->status === 'active') {
return $deliveryServer;
}
abort(404);
}
private function normalizeGrapesJsData(mixed $value): ?array
{
if ($value === null) {
return null;
}
if (is_array($value)) {
return $value;
}
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
return null;
}
$sanitized = $this->sanitizeUnlayerContent($decoded);
return is_array($sanitized) ? $sanitized : null;
}
private function stripLegacyUnsubscribeArtifacts(?string $content): ?string
{
if (!is_string($content) || $content === '') {
return $content;
}
$updated = preg_replace(
'/<p\b[^>]*>\s*<a\b[^>]*>\s*Unsubscribe(?:\s+from\s+this\s+list)?\s*<\/a>\s*<\/p>/i',
'',
$content
);
$updated = preg_replace('/\s*---\s*Unsubscribe:\s*https?:\/\/\S+/i', '', (string) $updated);
$updated = preg_replace('/(?:\r?\n\s*){3,}/', "\n\n", (string) $updated);
return is_string($updated) ? trim($updated) : $content;
}
private function sanitizeUnlayerContent(mixed $value): mixed
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = $this->sanitizeUnlayerContent($item);
}
return $value;
}
if (is_string($value)) {
return $this->stripLegacyUnsubscribeArtifacts($value) ?? $value;
}
return $value;
}
private function buildUnlayerData(mixed $value): ?array
{
$decoded = $this->normalizeGrapesJsData($value);
if ($decoded === null) {
return null;
}
return [
'builder' => 'unlayer',
'unlayer' => $decoded,
];
}
private function unlayerDesignFromTemplate(Template $template): ?array
{
$data = $template->grapesjs_data;
if (!is_array($data)) {
return null;
}
if (($data['builder'] ?? null) !== 'unlayer') {
return null;
}
$unlayer = $data['unlayer'] ?? null;
$sanitized = $this->sanitizeUnlayerContent($unlayer);
return is_array($sanitized) ? $sanitized : null;
}
private function customBuilderDataFromTemplate(Template $template): ?array
{
$data = $template->grapesjs_data;
if (!is_array($data)) {
return null;
}
if (($data['builder'] ?? null) !== 'custom') {
return null;
}
return $data;
}
private function htmlDoesNotContainDataImageRule(): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail): void {
if (!is_string($value) || $value === '') {
return;
}
if (stripos($value, 'data:image/') !== false) {
$fail('Inline base64 images (data:image/...) are not allowed. Upload images and use image URLs instead.');
return;
}
if (preg_match('/<img\b(?![^>]*\bsrc\s*=)[^>]*>/i', $value) === 1) {
$fail('One or more images are missing a src attribute. Please upload the image and ensure it has a valid URL.');
return;
}
if (preg_match('/<img\b[^>]*\bsrc\s*=\s*(["\"])\s*\1/i', $value) === 1) {
$fail('One or more images have an empty src. Please upload the image and ensure it has a valid URL.');
return;
}
if (preg_match_all('/<img\b[^>]*\bsrc\s*=\s*(["\"])\s*([^"\"]+)\s*\1/i', $value, $matches) > 0) {
foreach ($matches[2] as $src) {
$src = trim((string) $src);
if ($src === '') {
$fail('One or more images have an empty src. Please upload the image and ensure it has a valid URL.');
return;
}
$isAbsoluteHttp = preg_match('/^https?:\/\//i', $src) === 1;
$isProtocolRelative = str_starts_with($src, '//');
$isCid = preg_match('/^cid:/i', $src) === 1;
if (!$isAbsoluteHttp && !$isProtocolRelative && !$isCid) {
$fail('Image src must be an absolute URL (https://...) that is publicly accessible.');
return;
}
}
}
};
}
private function fileGalleryRoot(string $builder): string
{
$builder = trim($builder);
if ($builder === '') {
$builder = 'unlayer';
}
return resource_path('template-gallery/' . $builder);
}
private function fileGalleryItems(string $builder, string $tab): array
{
if (!in_array($builder, ['unlayer', 'text'], true)) {
return [];
}
$root = $this->fileGalleryRoot($builder);
if (!is_dir($root)) {
return [];
}
$dir = $tab === 'pro' ? ($root . DIRECTORY_SEPARATOR . 'pro') : $root;
if (!is_dir($dir)) {
return [];
}
$files = File::glob($dir . DIRECTORY_SEPARATOR . '*.json');
if (!is_array($files)) {
return [];
}
$items = [];
$customer = auth('customer')->user();
$groupIds = [];
if ($customer) {
$groupIds = $customer->customerGroups()
->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->values()
->all();
}
foreach ($files as $path) {
if (!is_string($path) || $path === '' || !is_file($path)) {
continue;
}
$relative = ltrim(str_replace($root, '', $path), DIRECTORY_SEPARATOR);
$key = sha1($builder . ':' . $relative);
$name = pathinfo($path, PATHINFO_FILENAME);
$description = null;
$thumbnail = null;
$category = 'other';
try {
$raw = File::get($path);
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
if (is_string($decoded['name'] ?? null) && trim((string) $decoded['name']) !== '') {
$name = trim((string) $decoded['name']);
}
if (is_string($decoded['description'] ?? null) && trim((string) $decoded['description']) !== '') {
$description = trim((string) $decoded['description']);
}
if (is_string($decoded['thumbnail'] ?? null) && trim((string) $decoded['thumbnail']) !== '') {
$thumbnail = trim((string) $decoded['thumbnail']);
}
if (is_string($decoded['category'] ?? null) && trim((string) $decoded['category']) !== '') {
$category = trim((string) $decoded['category']);
}
}
}
} catch (\Throwable $e) {
}
$setting = BuiltInTemplateSetting::firstOrCreate(
[
'builder' => $builder,
'template_key' => $key,
],
[
'relative_path' => $relative,
'name' => $name,
'is_active' => true,
]
);
if (($setting->name ?? null) !== $name || ($setting->relative_path ?? null) !== $relative) {
$setting->forceFill([
'name' => $name,
'relative_path' => $relative,
])->save();
}
if (!$setting->is_active) {
continue;
}
if (!$setting->available_to_all_groups) {
if (empty($groupIds)) {
continue;
}
$allowed = $setting->customerGroups()->whereIn('customer_groups.id', $groupIds)->exists();
if (!$allowed) {
continue;
}
}
$items[] = [
'id' => $key,
'name' => $name,
'description' => $description,
'thumbnail' => $thumbnail,
'category' => $category,
'builder' => $builder,
'source' => 'file',
'is_ai' => false,
'content_url' => route('customer.templates.import.file.content', ['key' => $key]) . '?builder=' . $builder,
];
}
usort($items, function ($a, $b) {
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return $items;
}
private function fileGalleryPayloadByKey(string $builder, string $key): ?array
{
if (!in_array($builder, ['unlayer', 'text'], true)) {
return null;
}
$root = $this->fileGalleryRoot($builder);
if (!is_dir($root)) {
return null;
}
$files = File::allFiles($root);
foreach ($files as $file) {
$path = $file->getPathname();
if (!is_string($path) || !str_ends_with(strtolower($path), '.json')) {
continue;
}
$relative = ltrim(str_replace($root, '', $path), DIRECTORY_SEPARATOR);
$computed = sha1($builder . ':' . $relative);
if (!hash_equals($computed, $key)) {
continue;
}
$setting = BuiltInTemplateSetting::firstOrCreate(
[
'builder' => $builder,
'template_key' => $key,
],
[
'relative_path' => $relative,
'name' => pathinfo($path, PATHINFO_FILENAME),
'is_active' => true,
]
);
if (!$setting->is_active) {
return null;
}
$customer = auth('customer')->user();
if ($customer) {
$groupIds = $customer->customerGroups()
->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->values()
->all();
if (!$setting->available_to_all_groups) {
$allowed = !empty($groupIds) && $setting->customerGroups()->whereIn('customer_groups.id', $groupIds)->exists();
if (!$allowed) {
abort(403, 'You do not have permission to access this template.');
}
}
}
$raw = File::get($path);
$decoded = json_decode((string) $raw, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
return null;
}
$design = null;
if (is_array($decoded['design'] ?? null)) {
$design = $decoded['design'];
} elseif (!isset($decoded['design'])) {
$design = $decoded;
}
if (!is_array($design)) {
return null;
}
$name = is_string($decoded['name'] ?? null) && trim((string) $decoded['name']) !== ''
? trim((string) $decoded['name'])
: pathinfo($path, PATHINFO_FILENAME);
if (($setting->name ?? null) !== $name || ($setting->relative_path ?? null) !== $relative) {
$setting->forceFill([
'name' => $name,
'relative_path' => $relative,
])->save();
}
$description = is_string($decoded['description'] ?? null) && trim((string) $decoded['description']) !== ''
? trim((string) $decoded['description'])
: null;
$plain = is_string($decoded['plain_text_content'] ?? null) ? (string) $decoded['plain_text_content'] : null;
if ($plain === null && is_string($decoded['plain'] ?? null)) {
$plain = (string) $decoded['plain'];
}
if ($builder === 'text') {
$html = is_string($decoded['html_content'] ?? null) ? (string) $decoded['html_content'] : '';
if ($plain === null) {
$plain = trim((string) preg_replace('/\s+/', ' ', preg_replace('/<[^>]+>/', ' ', preg_replace('/<style[\s\S]*?<\/style>/i', ' ', $html))));
}
return [
'id' => $key,
'name' => $name,
'description' => $description,
'html_content' => $html,
'plain_text_content' => $plain,
'builder' => 'text',
'builder_data' => ['builder' => 'text'],
];
}
$html = $this->unlayerPreviewHtmlFromDesign($design);
return [
'id' => $key,
'name' => $name,
'description' => $description,
'html_content' => $html,
'plain_text_content' => $plain,
'builder' => 'unlayer',
'builder_data' => $design,
];
}
return null;
}
private function unlayerPreviewHtmlFromDesign(array $design): string
{
$projectId = config('services.unlayer.project_id');
$projectId = is_numeric($projectId) ? (int) $projectId : null;
$designJson = json_encode(
$design,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
if (!is_string($designJson) || trim($designJson) === '') {
$designJson = 'null';
}
$projectIdJson = $projectId !== null ? json_encode($projectId) : 'null';
return <<<HTML
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; padding: 0; background: #f9fafb; height: 100%; }
#status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
font-size: 14px;
font-weight: 500;
color: #6b7280;
}
#status .spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#output { width: 100%; background: #ffffff; }
#editor { width: 1px; height: 1px; overflow: hidden; position: absolute; left: -9999px; top: -9999px; }
</style>
</head>
<body>
<div id="status"><div class="spinner"></div>Rendering preview…</div>
<div id="output"></div>
<div id="editor"></div>
<script src="https://editor.unlayer.com/embed.js"></script>
<script>
(function () {
try {
var design = $designJson;
var projectId = $projectIdJson;
if (!design || typeof design !== 'object') {
document.getElementById('status').textContent = 'Preview not available: invalid Unlayer design JSON.';
return;
}
var initOptions = { id: 'editor', displayMode: 'email' };
if (projectId) {
initOptions.projectId = Number(projectId);
}
unlayer.init(initOptions);
unlayer.addEventListener('editor:ready', function () {
unlayer.loadDesign(design);
unlayer.exportHtml(function (data) {
var html = (data && data.html) ? String(data.html) : '';
if (!html.trim()) {
document.getElementById('status').textContent = 'Preview not available.';
return;
}
document.getElementById('status').remove();
document.getElementById('output').innerHTML = html;
});
});
} catch (e) {
document.getElementById('status').textContent = 'Preview not available.';
}
})();
</script>
</body>
</html>
HTML;
}
private function renderUnlayerDesignToHtml(array $design): ?string
{
$apiKey = (string) config('services.unlayer.api_key');
$apiKey = trim($apiKey);
if ($apiKey === '') {
return null;
}
$cacheKey = 'unlayer:export_html:' . sha1(json_encode($design));
return Cache::remember($cacheKey, now()->addDay(), function () use ($apiKey, $design) {
try {
$res = Http::withHeaders([
'Authorization' => 'Basic ' . base64_encode($apiKey),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])->post('https://api.unlayer.com/v2/export/html', [
'displayMode' => 'email',
'design' => $design,
]);
if (!$res->ok()) {
return null;
}
$payload = $res->json();
$html = $payload['html'] ?? null;
if (!is_string($html) || trim($html) === '') {
return null;
}
return $html;
} catch (\Throwable $e) {
return null;
}
});
}
/**
* Display a listing of templates.
*/
public function index(Request $request)
{
$customer = auth('customer')->user();
$filters = $request->only(['search', 'type']);
$templates = $this->templateService->getPaginated($customer, $filters);
return view('customer.templates.index', compact('templates', 'filters'));
}
/**
* Show the form for creating a new template.
*/
public function create(Request $request)
{
$type = $request->query('type');
$name = (string) $request->query('name', '');
$builder = (string) $request->query('builder', 'unlayer');
if (!in_array($builder, ['unlayer', 'text'], true)) {
$builder = 'unlayer';
}
return view('customer.templates.unlayer.create', [
'type' => $type,
'name' => $name,
'initialBuilder' => $builder,
]);
}
public function createUnlayer(Request $request)
{
return $this->create($request);
}
/**
* Store a newly created template.
*/
public function store(Request $request)
{
// Handle base64 encoded content from unlayer editor
if (!empty($request->input('html_content_b64')) && $request->has('html_content')) {
$decoded = base64_decode((string) $request->input('html_content'), true);
if ($decoded !== false) {
$request->merge(['html_content' => $decoded]);
}
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'type' => ['nullable', 'in:email,campaign,transactional,autoresponder,footer,signature'],
'html_content' => ['nullable', 'string', $this->htmlDoesNotContainDataImageRule()],
'plain_text_content' => ['nullable', 'string'],
'grapesjs_data' => ['nullable'],
'settings' => ['nullable', 'array'],
'is_public' => ['nullable', 'boolean'],
]);
if (array_key_exists('html_content', $validated)) {
$validated['html_content'] = $this->stripLegacyUnsubscribeArtifacts($validated['html_content']);
}
if (array_key_exists('plain_text_content', $validated)) {
$validated['plain_text_content'] = $this->stripLegacyUnsubscribeArtifacts($validated['plain_text_content']);
}
$validated['grapesjs_data'] = $this->normalizeGrapesJsData($request->input('grapesjs_data'));
if (empty($validated['plain_text_content']) && !empty($validated['html_content'])) {
$validated['plain_text_content'] = trim(preg_replace('/\s+/', ' ', strip_tags($validated['html_content'])));
}
$customer = auth('customer')->user();
$template = $this->templateService->create($customer, $validated);
return redirect()
->route('customer.templates.show', $template)
->with('success', 'Template created successfully.');
}
public function storeUnlayer(Request $request)
{
return $this->store($request);
}
/**
* Display the specified template.
*/
public function show(Template $template)
{
$customer = auth('customer')->user();
if (!$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to view this template.');
}
$deliveryServers = $this->getSelectableDeliveryServers();
return view('customer.templates.show', compact('template', 'deliveryServers'));
}
public function sendTestEmail(Request $request, Template $template)
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
if (!$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to access this template.');
}
$validated = $request->validate([
'delivery_server_id' => ['required', 'integer', 'exists:delivery_servers,id'],
'to_email' => ['required', 'email', 'max:255'],
]);
$deliveryServer = $this->authorizeSelectableDeliveryServer((int) $validated['delivery_server_id']);
$deliveryServer->loadMissing('bounceServer');
try {
if ($deliveryServer->type === 'zeptomail-api') {
$fromEmail = $deliveryServer->from_email ?? config('mail.from.address');
$fromName = $deliveryServer->from_name ?? config('mail.from.name');
$html = $template->html_content ?? '';
$text = $template->plain_text_content ?? strip_tags($html);
$groupFooter = EmailFooter::forCustomer($customer);
if ($groupFooter !== '') {
$html = EmailFooter::appendHtml($html, $groupFooter);
$text = EmailFooter::appendText($text, $groupFooter);
}
app(ZeptoMailApiService::class)->sendRaw($deliveryServer, [
'from_email' => (string) $fromEmail,
'from_name' => (string) $fromName,
'to_email' => (string) $validated['to_email'],
'subject' => 'Test: ' . ($template->name ?? 'Template'),
'htmlbody' => (string) $html,
'textbody' => (string) $text,
'client_reference' => 'template-test-' . $template->id,
]);
return back()->with('success', 'Test email sent successfully!');
}
app(DeliveryServerService::class)->configureMailFromServer($deliveryServer);
$fromEmail = $deliveryServer->from_email ?? config('mail.from.address');
$fromName = $deliveryServer->from_name ?? config('mail.from.name');
$html = $template->html_content ?? '';
$text = $template->plain_text_content ?? strip_tags($html);
$groupFooter = EmailFooter::forCustomer($customer);
if ($groupFooter !== '') {
$html = EmailFooter::appendHtml($html, $groupFooter);
$text = EmailFooter::appendText($text, $groupFooter);
}
Mail::send([], [], function ($message) use ($validated, $template, $fromEmail, $fromName, $html, $text, $deliveryServer) {
$message->to($validated['to_email'])
->subject('Test: ' . ($template->name ?? 'Template'))
->from($fromEmail, $fromName);
if ($deliveryServer->bounceServer && !empty($deliveryServer->bounceServer->username)) {
$message->returnPath($deliveryServer->bounceServer->username);
}
if (!empty($html)) {
$message->html($html);
}
if (!empty($text)) {
$message->text($text);
}
});
return back()->with('success', 'Test email sent successfully!');
} catch (\Throwable $e) {
return back()->with('error', 'Failed to send test email: ' . $e->getMessage());
}
}
/**
* Show the form for editing the specified template.
*/
public function edit(Template $template)
{
$customer = auth('customer')->user();
if ($template->customer_id !== $customer->id && !$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to edit this template.');
}
return view('customer.templates.unlayer.edit', [
'template' => $template,
'customBuilderData' => $this->customBuilderDataFromTemplate($template),
]);
}
public function editUnlayer(Template $template)
{
return $this->edit($template);
}
/**
* Update the specified template.
*/
public function update(Request $request, Template $template)
{
// Check if user owns the template
$customer = auth('customer')->user();
if ($template->customer_id !== $customer->id) {
abort(403, 'You do not have permission to update this template.');
}
// Handle base64 encoded content from unlayer editor
if (!empty($request->input('html_content_b64')) && $request->has('html_content')) {
$decoded = base64_decode((string) $request->input('html_content'), true);
if ($decoded !== false) {
$request->merge(['html_content' => $decoded]);
}
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'type' => ['nullable', 'in:email,campaign,transactional,autoresponder,footer,signature'],
'html_content' => ['nullable', 'string', $this->htmlDoesNotContainDataImageRule()],
'plain_text_content' => ['nullable', 'string'],
'grapesjs_data' => ['nullable'],
'settings' => ['nullable', 'array'],
'is_public' => ['nullable', 'boolean'],
]);
if (array_key_exists('html_content', $validated)) {
$validated['html_content'] = $this->stripLegacyUnsubscribeArtifacts($validated['html_content']);
}
if (array_key_exists('plain_text_content', $validated)) {
$validated['plain_text_content'] = $this->stripLegacyUnsubscribeArtifacts($validated['plain_text_content']);
}
$validated['grapesjs_data'] = $this->normalizeGrapesJsData($request->input('grapesjs_data'));
if (empty($validated['plain_text_content']) && !empty($validated['html_content'])) {
$validated['plain_text_content'] = trim(preg_replace('/\s+/', ' ', strip_tags($validated['html_content'])));
}
$this->templateService->update($template, $validated);
if ($request->boolean('embedded') || $request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Template updated successfully.',
'template' => [
'id' => $template->id,
'name' => $template->name,
],
]);
}
return redirect()
->route('customer.templates.show', $template)
->with('success', 'Template updated successfully.');
}
public function updateUnlayer(Request $request, Template $template)
{
return $this->update($request, $template);
}
/**
* Remove the specified template.
*/
public function destroy(Template $template)
{
// Check if user owns the template
$customer = auth('customer')->user();
if ($template->customer_id !== $customer->id) {
abort(403, 'You do not have permission to delete this template.');
}
try {
$this->templateService->delete($template);
return redirect()
->route('customer.templates.index')
->with('success', 'Template deleted successfully.');
} catch (\Exception $e) {
return redirect()
->route('customer.templates.index')
->with('error', $e->getMessage());
}
}
/**
* Duplicate a template.
*/
public function duplicate(Template $template)
{
$customer = auth('customer')->user();
$newTemplate = $this->templateService->duplicate($template, $customer);
return redirect()
->route('customer.templates.unlayer.edit', $newTemplate)
->with('success', 'Template duplicated successfully.');
}
/**
* Preview template.
*/
public function preview(Template $template)
{
$customer = auth('customer')->user();
if (!$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to preview this template.');
}
return view('customer.templates.preview', compact('template'));
}
/**
* Get template content as JSON (for AJAX requests).
*/
public function getContent(Template $template)
{
// Check if user has access to this template
$customer = auth('customer')->user();
if (!$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to access this template.');
}
$rawBuilderData = $template->grapesjs_data;
$builder = is_array($rawBuilderData) ? ($rawBuilderData['builder'] ?? 'grapesjs') : null;
$builderData = $rawBuilderData;
if (is_array($rawBuilderData) && ($rawBuilderData['builder'] ?? null) === 'unlayer' && is_array($rawBuilderData['unlayer'] ?? null)) {
$builderData = $rawBuilderData['unlayer'];
$builder = 'unlayer';
}
return response()->json([
'html_content' => $template->html_content,
'plain_text_content' => $template->plain_text_content,
'builder' => $builder,
'builder_data' => $builderData,
]);
}
public function importGallery(Request $request)
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
$tab = (string) $request->query('tab', 'templates');
if ($tab !== 'templates') {
$tab = 'templates';
}
$builder = (string) $request->query('builder', 'unlayer');
if (!in_array($builder, ['grapesjs', 'unlayer', 'custom', 'text'], true)) {
$builder = 'unlayer';
}
$htmlBuilderMode = in_array($builder, ['grapesjs', 'custom'], true);
$systemTemplates = collect();
$aiTemplates = collect();
$publicTemplates = collect();
$publicCategories = collect();
try {
$systemTemplates = Template::query()
->where('is_system', true)
->where(function ($q) use ($customer) {
$groupIds = $customer->customerGroups()
->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->values()
->all();
$q->whereDoesntHave('customerGroups');
if (!empty($groupIds)) {
$q->orWhereHas('customerGroups', function ($sub) use ($groupIds) {
$sub->whereIn('customer_groups.id', $groupIds);
});
}
})
->when($builder === 'unlayer', function ($q) {
$q->where('grapesjs_data->builder', 'unlayer');
}, function ($q) use ($builder, $htmlBuilderMode) {
if ($builder === 'text') {
$q->where('grapesjs_data->builder', 'text');
return;
}
$q->where(function ($sub) {
$sub->whereNull('grapesjs_data->builder');
$sub->orWhere('grapesjs_data->builder', 'grapesjs');
$sub->orWhere('grapesjs_data->builder', 'custom');
});
})
->orderBy('name')
->get()
->map(function (Template $template) {
$rawBuilderData = $template->grapesjs_data;
$builder = is_array($rawBuilderData) ? ($rawBuilderData['builder'] ?? 'grapesjs') : 'grapesjs';
$settings = is_array($template->settings) ? $template->settings : [];
$category = $settings['category'] ?? 'other';
return [
'id' => $template->id,
'name' => $template->name,
'description' => $template->description,
'thumbnail' => $template->thumbnail,
'category' => $category,
'builder' => $builder,
'source' => 'db',
'is_ai' => false,
];
})
->values();
$aiTemplates = Template::query()
->where('is_system', false)
->where('customer_id', $customer->id)
->where('settings->origin', 'ai')
->when($builder === 'unlayer', function ($q) {
$q->where('grapesjs_data->builder', 'unlayer');
}, function ($q) use ($builder, $htmlBuilderMode) {
if ($builder === 'text') {
$q->where('grapesjs_data->builder', 'text');
return;
}
$q->where(function ($sub) {
$sub->whereNull('grapesjs_data->builder');
$sub->orWhere('grapesjs_data->builder', 'grapesjs');
$sub->orWhere('grapesjs_data->builder', 'custom');
});
})
->orderBy('name')
->get()
->map(function (Template $template) {
$rawBuilderData = $template->grapesjs_data;
$builder = is_array($rawBuilderData) ? ($rawBuilderData['builder'] ?? 'grapesjs') : 'grapesjs';
$settings = is_array($template->settings) ? $template->settings : [];
$origin = $settings['origin'] ?? null;
$isAi = $origin === 'ai' || ($origin === null && $template->name === 'AI Generated');
$category = $settings['category'] ?? 'ai';
return [
'id' => $template->id,
'name' => $template->name,
'description' => $template->description,
'thumbnail' => $template->thumbnail,
'category' => $category,
'builder' => $builder,
'source' => 'ai',
'is_ai' => $isAi,
];
})
->values();
$groupIds = $customer->customerGroups()
->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->values()
->all();
$publicCategories = PublicTemplateCategory::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get();
$publicTemplates = PublicTemplate::query()
->where('is_active', true)
->when($builder === 'custom', function ($q) {
$q->whereIn('builder', ['grapesjs', 'custom']);
}, function ($q) use ($builder) {
$q->where('builder', $builder);
})
->when($builder === 'text', function ($q) {
// text builder only matches text public templates
})
->where(function ($q) use ($groupIds) {
$q->where('available_to_all_groups', true);
if (!empty($groupIds)) {
$q->orWhereHas('customerGroups', function ($sub) use ($groupIds) {
$sub->whereIn('customer_groups.id', $groupIds);
})->where('available_to_all_groups', false);
}
})
->with('category')
->orderBy('name')
->get()
->map(function (PublicTemplate $template) {
$category = $template->category?->slug ?: 'other';
return [
'id' => 'public-' . $template->id,
'name' => $template->name,
'description' => $template->description,
'thumbnail' => $template->thumbnail,
'category' => $category,
'builder' => $template->builder,
'source' => 'public',
'is_ai' => false,
'content_url' => route('customer.templates.import.public.content', $template),
];
})
->values();
} catch (\Throwable $e) {
Log::warning('Template gallery DB query failed', [
'error' => $e->getMessage(),
'builder' => $builder,
]);
$systemTemplates = collect();
$aiTemplates = collect();
$publicTemplates = collect();
$publicCategories = collect();
}
$fileItems = $this->fileGalleryItems($builder, $tab);
$merged = array_values(array_merge($publicTemplates->all(), $systemTemplates->all(), $aiTemplates->all(), $fileItems));
usort($merged, function ($a, $b) {
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
$categories = [
['id' => 'all', 'name' => 'All Templates', 'icon' => 'grid'],
['id' => 'marketing', 'name' => 'Marketing & Promotional', 'icon' => 'megaphone'],
['id' => 'transactional', 'name' => 'Transactional', 'icon' => 'receipt'],
['id' => 'automation', 'name' => 'Automation & Drip', 'icon' => 'zap'],
['id' => 'cold', 'name' => 'Cold & Outreach', 'icon' => 'send'],
['id' => 'relationship', 'name' => 'Relationship & Engagement', 'icon' => 'heart'],
['id' => 'support', 'name' => 'Support & System', 'icon' => 'headphones'],
['id' => 'ecommerce', 'name' => 'E-commerce', 'icon' => 'shopping'],
['id' => 'ai', 'name' => 'AI Generated', 'icon' => 'sparkles'],
['id' => 'other', 'name' => 'Other', 'icon' => 'folder'],
];
if ($publicCategories->isNotEmpty()) {
$existingIds = collect($categories)->pluck('id')->map(fn ($id) => (string) $id)->all();
foreach ($publicCategories as $cat) {
$id = (string) ($cat->slug ?? '');
if ($id === '' || in_array($id, $existingIds, true)) {
continue;
}
$categories[] = [
'id' => $id,
'name' => (string) ($cat->name ?? $id),
'icon' => (string) ($cat->icon ?? 'folder'),
];
$existingIds[] = $id;
}
}
return response()->json([
'tab' => $tab,
'templates' => $merged,
'categories' => $categories,
]);
}
public function importFileContent(Request $request, string $key)
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
$builder = (string) $request->query('builder', 'unlayer');
if (!in_array($builder, ['unlayer', 'text'], true)) {
$builder = 'unlayer';
}
$payload = $this->fileGalleryPayloadByKey($builder, $key);
if ($payload === null) {
abort(404);
}
return response()->json($payload);
}
public function importContent(Template $template)
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
if (!$this->templateService->canAccessTemplate($template, $customer)) {
abort(403, 'You do not have permission to access this template.');
}
$rawBuilderData = $template->grapesjs_data;
$builder = is_array($rawBuilderData) ? ($rawBuilderData['builder'] ?? 'grapesjs') : null;
$builderData = $rawBuilderData;
if (is_array($rawBuilderData) && ($rawBuilderData['builder'] ?? null) === 'unlayer' && is_array($rawBuilderData['unlayer'] ?? null)) {
$builderData = $rawBuilderData['unlayer'];
$builder = 'unlayer';
}
return response()->json([
'id' => $template->id,
'name' => $template->name,
'description' => $template->description,
'html_content' => $template->html_content,
'plain_text_content' => $template->plain_text_content,
'builder' => $builder,
'builder_data' => $builderData,
]);
}
public function importPublicContent(PublicTemplate $publicTemplate)
{
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
if (!$publicTemplate->is_active) {
abort(404);
}
$groupIds = $customer->customerGroups()
->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->values()
->all();
if (!$publicTemplate->available_to_all_groups) {
$allowed = !empty($groupIds) && $publicTemplate->customerGroups()->whereIn('customer_groups.id', $groupIds)->exists();
if (!$allowed) {
abort(403, 'You do not have permission to access this template.');
}
}
return response()->json([
'id' => $publicTemplate->id,
'name' => $publicTemplate->name,
'description' => $publicTemplate->description,
'html_content' => $publicTemplate->html_content,
'plain_text_content' => $publicTemplate->plain_text_content,
'builder' => $publicTemplate->builder,
'builder_data' => $publicTemplate->builder_data,
]);
}
public function aiGenerate(Request $request)
{
$validated = $request->validate([
'provider' => ['required', 'string', 'in:chatgpt,gemini,claude'],
'model' => ['nullable', 'string', 'max:100'],
'prompt' => ['required', 'string', 'max:5000'],
'builder' => ['nullable', 'string', 'in:grapesjs,unlayer'],
]);
$customer = auth('customer')->user();
if (!$customer) {
abort(404);
}
$provider = (string) ($validated['provider'] ?? '');
$model = is_string($validated['model'] ?? null) && trim((string) $validated['model']) !== ''
? trim((string) $validated['model'])
: null;
$prompt = (string) ($validated['prompt'] ?? '');
$builder = is_string($validated['builder'] ?? null) && trim((string) $validated['builder']) !== ''
? (string) $validated['builder']
: 'grapesjs';
$log = AiGeneration::create([
'customer_id' => $customer->id,
'admin_user_id' => null,
'tool' => 'template_generator',
'provider' => strtolower(trim($provider)),
'model' => $model,
'used_admin_keys' => false,
'prompt' => (string) $prompt,
'input' => [
'provider' => $provider,
'model' => $model,
'builder' => $builder,
],
'success' => false,
'output' => null,
'tokens_used' => null,
'error_message' => null,
]);
$result = $this->aiTemplateService->generate($customer, $provider, $prompt, $builder, $model);
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
$log->used_admin_keys = (bool) ($data['used_admin_keys'] ?? false);
$log->tokens_used = is_numeric($data['tokens_used'] ?? null) ? (int) $data['tokens_used'] : null;
$rawHtml = is_string($data['html_content'] ?? null) ? (string) $data['html_content'] : null;
if (!($result['success'] ?? false)) {
$status = is_numeric($result['status'] ?? null) ? (int) $result['status'] : 500;
$message = is_string($result['message'] ?? null) ? $result['message'] : 'Failed to generate template.';
$log->success = false;
$log->output = null;
$log->error_message = $message;
$log->save();
return response()->json([
'message' => $message,
], $status);
}
$log->success = true;
$log->error_message = null;
$log->output = $rawHtml;
$log->save();
if (($data['builder'] ?? null) === 'unlayer' && is_array($data['builder_data'] ?? null)) {
$data['html_content'] = $this->unlayerPreviewHtmlFromDesign($data['builder_data']);
}
return response()->json($data, 200);
}
}