Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/Http/Controllers/CheckoutConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
]);
Expand Down Expand Up @@ -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]);
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
123 changes: 122 additions & 1 deletion app/Plugins/PluginRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,15 @@ 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.
// Se o plugin.json da cópia for minimalista, preservar blocos de UI já lidos
// 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;
Expand Down Expand Up @@ -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<int, array{id: string, name: string, description?: string|null, plugin: string, css_url?: string|null}>
*/
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).
*
Expand Down
6 changes: 4 additions & 2 deletions resources/js/Pages/Checkout/Builder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [] },
});

Expand Down Expand Up @@ -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';
Expand Down
17 changes: 17 additions & 0 deletions resources/js/Pages/Checkout/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({}) },
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -353,10 +368,12 @@ const hasCustomBodyEnd = computed(() => String(customBodyEndHtml.value).trim() !
fetchpriority="high"
/>
<link rel="icon" :href="faviconHref" />
<link v-if="checkoutTemplateCssUrl" rel="stylesheet" :href="checkoutTemplateCssUrl" />
</Head>
<div
id="getfy-checkout-root"
data-checkout="page"
:data-checkout-template="selectedTemplateId"
class="min-h-screen transition-colors duration-300"
:style="{ backgroundColor }"
>
Expand Down