451 lines
18 KiB
PHP
451 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\HeaderTemplate;
|
|
use App\Models\SectionTemplate;
|
|
use App\Models\FooterTemplate;
|
|
use Filament\Forms\Components\{
|
|
TextInput, Textarea, Select, Checkbox, CheckboxList,
|
|
Radio, Toggle, ToggleButtons, DateTimePicker, DatePicker,
|
|
TimePicker, FileUpload, RichEditor, MarkdownEditor,
|
|
ColorPicker, TagsInput, KeyValue, CodeEditor, Hidden, Slider
|
|
};
|
|
|
|
class TemplateService
|
|
{
|
|
/**
|
|
* Generate dynamic form fields based on template placeholders
|
|
* For default data (template forms), use dataKey = 'default_data'
|
|
* For page data (page forms), use dataKey = 'header_data' or 'footer_data'
|
|
*
|
|
* @param array|null $defaultData Template's default_data array
|
|
*/
|
|
public static function generateDynamicFields($template, string $dataKey, ?array $existingData = null, ?array $defaultData = null): array
|
|
{
|
|
if (!$template) {
|
|
return [];
|
|
}
|
|
|
|
$placeholders = self::parsePlaceholders($template->html_content);
|
|
$fields = [];
|
|
|
|
foreach ($placeholders as $placeholder) {
|
|
$parts = explode('.', $placeholder);
|
|
|
|
if (count($parts) !== 2) {
|
|
continue; // Skip invalid placeholders
|
|
}
|
|
|
|
[$type, $name] = $parts;
|
|
|
|
// Skip custom.* placeholders - they are blade components, not form fields
|
|
if ($type === 'custom') {
|
|
continue;
|
|
}
|
|
|
|
$label = str($name)->title()->replace('_', ' ')->toString();
|
|
|
|
// Get existing value from existingData if provided
|
|
$existingValue = $existingData[$placeholder] ?? null;
|
|
|
|
// Get default value from template's default_data
|
|
$defaultValue = $defaultData[$placeholder] ?? null;
|
|
|
|
// If existing value is empty (null, empty string, empty array), use default
|
|
$isValueEmpty = $existingValue === null
|
|
|| $existingValue === ''
|
|
|| (is_array($existingValue) && empty($existingValue));
|
|
|
|
// Use default if existing value is empty
|
|
$finalValue = $isValueEmpty && $defaultValue !== null ? $defaultValue : $existingValue;
|
|
|
|
$field = match($type) {
|
|
// Text Input Variants
|
|
'text' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->maxLength(255),
|
|
|
|
'email' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->email()
|
|
->maxLength(255),
|
|
|
|
'url' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->url()
|
|
->maxLength(500),
|
|
|
|
'tel' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->tel()
|
|
->maxLength(20),
|
|
|
|
'number' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->numeric(),
|
|
|
|
'password' => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->password()
|
|
->dehydrated(fn ($state) => filled($state))
|
|
->maxLength(255),
|
|
|
|
// Text Area & Editors
|
|
'textarea' => Textarea::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->rows(4)
|
|
->maxLength(5000),
|
|
|
|
'richtext' => RichEditor::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->toolbarButtons([
|
|
'bold', 'italic', 'underline', 'link',
|
|
'bulletList', 'orderedList', 'h2', 'h3',
|
|
])
|
|
->maxLength(50000),
|
|
|
|
'markdown' => MarkdownEditor::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->toolbarButtons([
|
|
'bold', 'italic', 'strike', 'link',
|
|
'heading', 'bulletList', 'orderedList', 'codeBlock',
|
|
])
|
|
->maxLength(50000),
|
|
|
|
'code' => CodeEditor::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
//->lineNumbers()
|
|
->maxLength(50000),
|
|
|
|
// Date & Time
|
|
'date' => DatePicker::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->native(false),
|
|
|
|
'datetime' => DateTimePicker::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->native(false)
|
|
->seconds(false),
|
|
|
|
'time' => TimePicker::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->native(false)
|
|
->seconds(false),
|
|
|
|
// File Uploads
|
|
'image' => FileUpload::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->image()
|
|
->disk('public')
|
|
->directory('templates/images')
|
|
->imageEditor()
|
|
->imageEditorAspectRatios([null, '16:9', '4:3', '1:1'])
|
|
->maxSize(5120),
|
|
|
|
'images' => FileUpload::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->image()
|
|
->disk('public')
|
|
->directory('templates/images')
|
|
->multiple()
|
|
->imageEditor()
|
|
->maxSize(5120)
|
|
->maxFiles(10),
|
|
|
|
'file' => FileUpload::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->disk('public')
|
|
->directory('templates/files')
|
|
->maxSize(10240),
|
|
|
|
'files' => FileUpload::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->disk('public')
|
|
->directory('templates/files')
|
|
->multiple()
|
|
->maxSize(10240)
|
|
->maxFiles(10),
|
|
|
|
// Selection & Options
|
|
'select' => Select::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->options(self::getSelectOptions($name))
|
|
->searchable(),
|
|
|
|
'multiselect' => Select::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->multiple()
|
|
->options(self::getSelectOptions($name))
|
|
->searchable(),
|
|
|
|
'checkbox' => Checkbox::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->inline(false),
|
|
|
|
'checkboxlist' => CheckboxList::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->options(self::getSelectOptions($name))
|
|
->columns(2),
|
|
|
|
'radio' => Radio::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->options(self::getSelectOptions($name))
|
|
->inline()
|
|
->inlineLabel(false),
|
|
|
|
'toggle' => Toggle::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->inline(false),
|
|
|
|
'togglebuttons' => ToggleButtons::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->options(self::getSelectOptions($name))
|
|
->inline()
|
|
->grouped(),
|
|
|
|
// Color & Visual
|
|
'color' => ColorPicker::make("{$dataKey}.{$placeholder}")
|
|
->label($label),
|
|
|
|
// Structured Data
|
|
'tags' => TagsInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->separator(','),
|
|
|
|
'keyvalue' => KeyValue::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->keyLabel('Key')
|
|
->valueLabel('Value')
|
|
->reorderable(),
|
|
|
|
// Advanced
|
|
'slider' => Slider::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->minValue(0)
|
|
->maxValue(100)
|
|
->step(1),
|
|
|
|
'hidden' => Hidden::make("{$dataKey}.{$placeholder}"),
|
|
|
|
// Default
|
|
default => TextInput::make("{$dataKey}.{$placeholder}")
|
|
->label($label)
|
|
->maxLength(255)
|
|
->helperText("Unknown type: {$type}"),
|
|
};
|
|
|
|
// Set default value: use finalValue (which falls back to default if existing is empty)
|
|
if ($finalValue !== null) {
|
|
$field->default($finalValue);
|
|
}
|
|
|
|
$fields[] = $field;
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* Parse placeholders from HTML content
|
|
* Format: {type.field_name}
|
|
*/
|
|
public static function parsePlaceholders(string $html): array
|
|
{
|
|
preg_match_all('/\{([a-z]+\.[a-z_]+)\}/i', $html, $matches);
|
|
return array_unique($matches[1] ?? []);
|
|
}
|
|
|
|
/**
|
|
* Get select options for a field
|
|
* Can be extended to load from database or config
|
|
*/
|
|
protected static function getSelectOptions(string $fieldName): array
|
|
{
|
|
return config("template-options.{$fieldName}", [
|
|
'option_1' => __('Option 1'),
|
|
'option_2' => __('Option 2'),
|
|
'option_3' => __('Option 3'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Replace placeholders in HTML with actual data
|
|
* Supports both {text.title} and {{text.title}} formats
|
|
* Also supports {menu} and {staticMenu} placeholders for menu rendering
|
|
* Also supports {custom.component_name} for custom blade components
|
|
*/
|
|
public static function replacePlaceholders(string $html, array $data): string
|
|
{
|
|
// Handle special {menu} placeholder first
|
|
if (str_contains($html, '{menu}')) {
|
|
$renderedMenu = \App\Services\MenuService::render();
|
|
$html = str_replace('{menu}', $renderedMenu, $html);
|
|
}
|
|
|
|
// Handle special {staticMenu} placeholder
|
|
if (str_contains($html, '{staticMenu}')) {
|
|
$renderedStaticMenu = view('components.static-menu')->render();
|
|
$html = str_replace('{staticMenu}', $renderedStaticMenu, $html);
|
|
}
|
|
|
|
// Handle custom blade components: {custom.component_name}
|
|
preg_match_all('/\{custom\.([a-z_]+)\}/i', $html, $customMatches);
|
|
if (!empty($customMatches[0])) {
|
|
foreach ($customMatches[0] as $index => $fullMatch) {
|
|
$componentName = $customMatches[1][$index] ?? null;
|
|
if ($componentName) {
|
|
// Normalize component name to lowercase for consistent file system access
|
|
$componentName = strtolower($componentName);
|
|
$viewPath = "components.custom.{$componentName}";
|
|
if (view()->exists($viewPath)) {
|
|
try {
|
|
$renderedComponent = view($viewPath)->render();
|
|
// Ensure rendered component is properly formatted
|
|
$renderedComponent = trim($renderedComponent);
|
|
$html = str_replace($fullMatch, $renderedComponent, $html);
|
|
} catch (\Exception $e) {
|
|
// Log the error for debugging
|
|
\Log::error("Custom component render error: {$viewPath}", [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString()
|
|
]);
|
|
|
|
// Show error message in preview (visible in development)
|
|
$errorMessage = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
|
|
$errorHtml = "<div style='padding: 10px; background: #fee; border: 1px solid #fcc; color: #c00; margin: 5px 0; border-radius: 4px;'>" .
|
|
"<strong>Custom Component Error:</strong> {$viewPath}<br>" .
|
|
"<small>{$errorMessage}</small>" .
|
|
"</div>";
|
|
$html = str_replace($fullMatch, $errorHtml, $html);
|
|
}
|
|
} else {
|
|
// Component doesn't exist - show notice in preview
|
|
$noticeHtml = "<div style='padding: 10px; background: #fff3cd; border: 1px solid #ffc107; color: #856404; margin: 5px 0; border-radius: 4px;'>" .
|
|
"<strong>Custom Component Not Found:</strong> {$viewPath}<br>" .
|
|
"<small>Create the file at: resources/views/components/custom/{$componentName}.blade.php</small>" .
|
|
"</div>";
|
|
$html = str_replace($fullMatch, $noticeHtml, $html);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// First, parse all placeholders in the format {type.field_name}
|
|
// Exclude custom.* from this regex to avoid double processing
|
|
preg_match_all('/\{([a-z]+\.[a-z_]+)\}/i', $html, $matches);
|
|
$placeholders = array_unique($matches[1] ?? []);
|
|
|
|
// Filter out custom.* placeholders as they're already handled above
|
|
$placeholders = array_filter($placeholders, function($placeholder) {
|
|
return !str_starts_with($placeholder, 'custom.');
|
|
});
|
|
|
|
// Replace each placeholder
|
|
foreach ($placeholders as $placeholder) {
|
|
// Try to find the value in data with various key formats
|
|
$value = null;
|
|
|
|
// 1. Try exact match (text.title)
|
|
if (isset($data[$placeholder])) {
|
|
$value = $data[$placeholder];
|
|
}
|
|
// 2. Try with section_data prefix (section_data.text.title)
|
|
elseif (isset($data["section_data.{$placeholder}"])) {
|
|
$value = $data["section_data.{$placeholder}"];
|
|
}
|
|
// 3. Try nested array access (data['section_data']['text.title'] or data['text']['title'])
|
|
else {
|
|
$parts = explode('.', $placeholder);
|
|
if (count($parts) === 2) {
|
|
[$type, $field] = $parts;
|
|
// Try nested: data['section_data'][$type][$field]
|
|
if (isset($data['section_data'][$type][$field])) {
|
|
$value = $data['section_data'][$type][$field];
|
|
}
|
|
// Try nested: data[$type][$field]
|
|
elseif (isset($data[$type][$field])) {
|
|
$value = $data[$type][$field];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract type from placeholder to determine if it's a file/image
|
|
$parts = explode('.', $placeholder);
|
|
$type = $parts[0] ?? null;
|
|
|
|
// Handle different value types
|
|
if (is_array($value)) {
|
|
// Handle arrays (e.g., multiple images)
|
|
$value = array_map(function($item) use ($type) {
|
|
return self::formatFileUrl($item, $type);
|
|
}, $value);
|
|
$value = implode(', ', $value);
|
|
} elseif (is_bool($value)) {
|
|
$value = $value ? 'true' : 'false';
|
|
} elseif ($value === null) {
|
|
$value = '';
|
|
} else {
|
|
// Format file/image URLs
|
|
$value = self::formatFileUrl($value, $type);
|
|
}
|
|
|
|
// Replace both {text.title} and {{text.title}} formats
|
|
$html = str_replace(
|
|
["{{$placeholder}}", "{{{$placeholder}}}"],
|
|
[$value, $value],
|
|
$html
|
|
);
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Format file URL based on type
|
|
* Converts storage paths to public URLs
|
|
*/
|
|
protected static function formatFileUrl($value, $type): string
|
|
{
|
|
// If it's already a full URL, return as is
|
|
if (is_string($value) && (str_starts_with($value, 'http://') || str_starts_with($value, 'https://'))) {
|
|
return $value;
|
|
}
|
|
|
|
// Only process image and file types
|
|
if (!in_array($type, ['image', 'images', 'file', 'files'])) {
|
|
return (string) $value;
|
|
}
|
|
|
|
// If value is empty, return empty string
|
|
if (empty($value) || !is_string($value)) {
|
|
return '';
|
|
}
|
|
|
|
// Check if the path starts with storage/ (already formatted)
|
|
if (str_starts_with($value, 'storage/')) {
|
|
return asset($value);
|
|
}
|
|
|
|
// Remove private/ prefix if exists (old format from private disk)
|
|
if (str_starts_with($value, 'private/')) {
|
|
$value = str_replace('private/', '', $value);
|
|
}
|
|
|
|
// Check if the path is a relative path that should be in storage
|
|
// Templates are stored in templates/images or templates/files
|
|
if (str_starts_with($value, 'templates/')) {
|
|
return asset('storage/' . $value);
|
|
}
|
|
|
|
// If it's already using asset() or Storage::url(), assume it's already formatted
|
|
// Otherwise, prepend storage/ if it looks like a storage path
|
|
if (!str_starts_with($value, '/')) {
|
|
return asset('storage/' . $value);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|
|
|