diff --git a/app/Http/Controllers/CheckoutConfigController.php b/app/Http/Controllers/CheckoutConfigController.php index 796f1739..ea3df457 100644 --- a/app/Http/Controllers/CheckoutConfigController.php +++ b/app/Http/Controllers/CheckoutConfigController.php @@ -5,6 +5,7 @@ use App\Models\Coupon; use App\Models\Product; use App\Models\ProductOffer; +use App\Plugins\PluginRegistry; use App\Services\StorageService; use App\Models\SubscriptionPlan; use Illuminate\Http\JsonResponse; @@ -67,6 +68,7 @@ public function edit(Request $request, Product $produto): Response ], 'config' => $config, 'checkout_scope' => $scope, + 'checkout_templates' => PluginRegistry::getCheckoutTemplates(), 'cupons' => $cupons, 'layoutFullWidth' => true, ]); @@ -108,6 +110,10 @@ public function update(Request $request, Product $produto): RedirectResponse // Oferta/plano: não persistir chaves mantidas só no produto (Builder não as envia; gravar defaults // anularia payment_gateways no merge público — ver CheckoutController). + if (! PluginRegistry::checkoutTemplateExists($merged['template'] ?? 'original')) { + $merged['template'] = 'original'; + } + if ($offerId || $planId) { foreach (['payment_gateways', 'card_installments', 'stripe_link_enabled', 'deliverable_link', 'email_template'] as $inheritKey) { unset($merged[$inheritKey]); diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 69464e58..3449042e 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -21,6 +21,7 @@ use App\Models\Subscription; use App\Models\SubscriptionPlan; use App\Models\User; +use App\Plugins\PluginRegistry; use App\Services\GeoIp; use App\Services\EfiPixRecorrenteService; use App\Services\StorageService; @@ -183,6 +184,8 @@ public function show(Request $request, string $slug): Response $payload = [ 'product' => $productArray, 'config' => $config, + 'checkout_templates' => PluginRegistry::getCheckoutTemplates(), + 'checkout_template' => PluginRegistry::resolveCheckoutTemplate($config['template'] ?? 'original'), ]; $payload['offer'] = $resolved['offer'] ? [ 'id' => $resolved['offer']->id, diff --git a/app/Plugins/PluginRegistry.php b/app/Plugins/PluginRegistry.php index 478e54e1..4ff1fc24 100644 --- a/app/Plugins/PluginRegistry.php +++ b/app/Plugins/PluginRegistry.php @@ -235,6 +235,7 @@ private static function collectDiskPluginsBySlug(): array 'settings_tab' => $manifest['settings_tab'] ?? null, 'integration_app' => $manifest['integration_app'] ?? null, 'product_panel' => $manifest['product_panel'] ?? null, + 'checkout_templates' => $manifest['checkout_templates'] ?? [], ]; // Uma instalação persistente (ex.: ZIP) no mesmo slug sobrescreve a pasta bundled. @@ -242,7 +243,7 @@ private static function collectDiskPluginsBySlug(): array // de uma deteção anterior (ex.: integração / painel no produto). if (isset($bySlug[$slug])) { $prev = $bySlug[$slug]; - foreach (['integration_app', 'product_panel', 'settings_tab'] as $uiKey) { + foreach (['integration_app', 'product_panel', 'settings_tab', 'checkout_templates'] as $uiKey) { $val = $row[$uiKey] ?? null; if ($val === null || (is_array($val) && $val === [])) { $p = $prev[$uiKey] ?? null; @@ -396,6 +397,126 @@ public static function getProductPanels(): array return $items; } + /** + * Templates de checkout declarados por plugins ativos. + * + * Por segurança, templates de plugins registram apenas metadados e CSS servido + * pela rota protegida de assets do plugin. HTML/JS arbitrário não é aceito aqui. + * + * @return array + */ + public static function getCheckoutTemplates(): array + { + $items = []; + foreach (self::enabled() as $plugin) { + $templates = $plugin['checkout_templates'] ?? []; + if (! is_array($templates)) { + continue; + } + + $slug = trim((string) ($plugin['slug'] ?? '')); + if ($slug === '') { + continue; + } + + foreach ($templates as $template) { + if (! is_array($template)) { + continue; + } + + $id = self::normalizeCheckoutTemplateId((string) ($template['id'] ?? '')); + $name = self::normalizeCheckoutTemplateText((string) ($template['name'] ?? ''), 80); + if ($id === null || $name === '') { + continue; + } + + $css = self::normalizeCheckoutTemplateAssetPath((string) ($template['css'] ?? '')); + if ($css === null) { + continue; + } + + try { + $cssUrl = URL::route('plugins.asset', ['slug' => $slug, 'path' => $css]); + } catch (\Throwable) { + continue; + } + + $description = self::normalizeCheckoutTemplateText((string) ($template['description'] ?? ''), 180); + $items[] = [ + 'id' => $slug.'::'.$id, + 'name' => $name, + 'description' => $description !== '' ? $description : null, + 'plugin' => $slug, + 'css_url' => $cssUrl, + ]; + } + } + + return $items; + } + + /** + * @return array{id: string, name: string, description?: string|null, plugin: string, css_url?: string|null}|null + */ + public static function resolveCheckoutTemplate(?string $id): ?array + { + $id = trim((string) $id); + if ($id === '' || $id === 'original') { + return null; + } + + foreach (self::getCheckoutTemplates() as $template) { + if (($template['id'] ?? '') === $id) { + return $template; + } + } + + return null; + } + + public static function checkoutTemplateExists(?string $id): bool + { + $id = trim((string) $id); + if ($id === '' || $id === 'original') { + return true; + } + + return self::resolveCheckoutTemplate($id) !== null; + } + + private static function normalizeCheckoutTemplateId(string $id): ?string + { + $id = strtolower(trim($id)); + if (! preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $id)) { + return null; + } + + return $id; + } + + private static function normalizeCheckoutTemplateText(string $value, int $maxLength): string + { + $value = trim(strip_tags($value)); + if ($value === '') { + return ''; + } + + return mb_substr($value, 0, $maxLength); + } + + private static function normalizeCheckoutTemplateAssetPath(string $path): ?string + { + $path = trim(str_replace('\\', '/', $path)); + if ($path === '' || str_starts_with($path, '/') || str_contains($path, '://') || str_contains($path, '..')) { + return null; + } + if (! str_ends_with(strtolower($path), '.css')) { + return null; + } + + return ltrim($path, '/'); + } + /** * Only plugins that are enabled (for loading bootstrap and routes). * diff --git a/resources/js/Pages/Checkout/Builder.vue b/resources/js/Pages/Checkout/Builder.vue index 955745b3..92654537 100644 --- a/resources/js/Pages/Checkout/Builder.vue +++ b/resources/js/Pages/Checkout/Builder.vue @@ -36,6 +36,7 @@ const props = defineProps({ type: Object, default: () => ({ type: 'main', offer_id: null, plan_id: null, checkout_slug: null, label: '' }), }, + checkout_templates: { type: Array, default: () => [] }, cupons: { type: Array, default: () => [] }, }); @@ -294,9 +295,10 @@ const advancedEditorTabs = [ ]; /** Templates de checkout disponíveis. Pode ser estendido por plugins (registro de templates). */ -const availableCheckoutTemplates = [ +const availableCheckoutTemplates = computed(() => [ { id: 'original', name: 'Original', description: 'Layout padrão do checkout (resumo, formulário e sidebar).' }, -]; + ...props.checkout_templates, +]); const inputClass = 'block w-full rounded-xl border-2 border-zinc-200 bg-white px-4 py-2.5 text-zinc-900 placeholder-zinc-400 transition focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 dark:border-zinc-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-500'; diff --git a/resources/js/Pages/Checkout/Show.vue b/resources/js/Pages/Checkout/Show.vue index b69e9d77..8b61e1d9 100644 --- a/resources/js/Pages/Checkout/Show.vue +++ b/resources/js/Pages/Checkout/Show.vue @@ -22,6 +22,8 @@ const PREVIEW_MESSAGE_TYPE = 'checkout-builder-preview-config'; const props = defineProps({ product: { type: Object, required: true }, config: { type: Object, default: () => ({}) }, + checkout_templates: { type: Array, default: () => [] }, + checkout_template: { type: Object, default: null }, checkout_session_token: { type: String, default: '' }, available_payment_methods: { type: Array, default: () => [] }, flash: { type: Object, default: () => ({}) }, @@ -73,6 +75,19 @@ const effectiveConfig = computed(() => { return props.config; }); +const selectedTemplateId = computed(() => effectiveConfig.value?.template || 'original'); +const activeCheckoutTemplate = computed(() => { + if (selectedTemplateId.value === 'original') { + return null; + } + const listed = (props.checkout_templates || []).find((tpl) => tpl?.id === selectedTemplateId.value); + if (listed) { + return listed; + } + return props.checkout_template?.id === selectedTemplateId.value ? props.checkout_template : null; +}); +const checkoutTemplateCssUrl = computed(() => activeCheckoutTemplate.value?.css_url || null); + /** Listener no setup (não só no onMounted) para não perder postMessage se o parent disparar no @load do iframe antes do mount. */ if (typeof window !== 'undefined' && props.checkout_builder_preview) { window.addEventListener('message', onPreviewMessage); @@ -353,10 +368,12 @@ const hasCustomBodyEnd = computed(() => String(customBodyEndHtml.value).trim() ! fetchpriority="high" /> +