From a087a929b2300bb1bf3b6ecab93a66a2a2e20270 Mon Sep 17 00:00:00 2001
From: Impa365 <91766929+impa365@users.noreply.github.com>
Date: Thu, 12 Mar 2026 13:38:38 -0300
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20painel=20de=20marca=20(white-label)?=
=?UTF-8?q?,=20corre=C3=A7=C3=A3o=20de=20build=20e=20an=C3=A1lise=20de=20p?=
=?UTF-8?q?lugins?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. Correção de build — Case-sensitivity no Vite (Linux/Docker)
O build do frontend falhava no GitHub Actions (Linux) por causa de imports com letra maiúscula @/Components/ em vez de @/components/.
Index.vue: Corrigido 2 imports de @/Components/EmailProviderSidebar.vue → @/components/EmailProviderSidebar.vue
2. Nova aba "Marca" nas Configurações (white-label completo)
Permite alterar nome, descrição, cor primária, logos e favicon da plataforma diretamente pelo painel admin, sem precisar mexer em variáveis de ambiente.
Backend:
SettingsController.php:
index() agora passa dados de branding do banco (com fallback para getfy.php)
update() salva campos de branding (app_name, app_description, theme_primary, logos, favicon)
Novo método uploadBranding() para upload de imagens via endpoint dedicado
web.php: Nova rota POST /configuracoes/branding/upload
HandleInertiaRequests.php: Branding agora lê do banco primeiro (Setting::get()) com fallback para config
PanelPwaController.php: Manifest PWA usa branding do banco
EmailTestController.php: Nome do app nos emails vem do banco
app.blade.php: Title, favicon e theme-color do HTML leem do banco
Frontend:
Index.vue:
Nova aba "Marca" com campos: nome, descrição, cor primária (color picker), 4 uploads de logo (horizontal claro/escuro, ícone claro/escuro) e favicon
Tamanhos recomendados exibidos em cada card de upload
Preview das imagens após upload
3. Restrição da aba "Marca" apenas para admin (master)
Frontend: A aba "Marca" só aparece se auth.user.role === 'admin'; tabs são computados dinamicamente
Backend: uploadBranding() retorna 403 se não for admin; update() ignora campos de branding se não for admin
Arquivos modificados
Arquivo Tipo de alteração
Index.vue Fix imports + nova aba Marca + restrição admin
SettingsController.php Branding CRUD + upload + guard admin
web.php Nova rota branding upload
HandleInertiaRequests.php Branding do banco com fallback
PanelPwaController.php PWA manifest do banco
EmailTestController.php App name do banco
app.blade.php HTML title/favicon/theme do banco
Notas
Sem migrations novas — usa a tabela settings existente (key-value com tenant_id)
Sem breaking changes — tudo tem fallback para os valores do getfy.php (variáveis de ambiente)
O sistema de plugins foi analisado e está completo, apenas desabilitado intencionalmente com overlay "Em breve.."
---
.dockerignore | 37 ++
.github/workflows/docker-build.yml | 48 +++
.gitignore | 3 +-
Dockerfile | 27 +-
README.md | 5 -
app/Http/Controllers/DashboardController.php | 4 +-
app/Http/Controllers/EmailTestController.php | 2 +-
app/Http/Controllers/PanelPwaController.php | 8 +-
app/Http/Controllers/RelatoriosController.php | 2 +-
app/Http/Controllers/SettingsController.php | 56 ++-
app/Http/Middleware/HandleInertiaRequests.php | 15 +-
config/getfy.php | 12 +-
...2_26_000001_change_products_id_to_uuid.php | 95 ++++-
...ers_subscription_plans_product_id_uuid.php | 17 +
...kout_slug_nullable_on_offers_and_plans.php | 14 +
...0001_fix_member_tables_product_id_uuid.php | 29 ++
..._fix_checkout_sessions_product_id_uuid.php | 15 +
...00001_fix_product_user_product_id_uuid.php | 20 ++
...0002_fix_subscriptions_product_id_uuid.php | 16 +
...0001_create_member_notifications_table.php | 2 +-
docker/entrypoint.sh | 35 +-
resources/js/Pages/Settings/Index.vue | 330 +++++++++++++++++-
resources/views/app.blade.php | 11 +-
routes/web.php | 1 +
24 files changed, 748 insertions(+), 56 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/docker-build.yml
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..a4d43a6c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,37 @@
+# Dependencies
+/vendor
+/node_modules
+
+# Environment
+.env
+.env.*
+!.env.example
+
+# IDE
+/.idea
+/.vscode
+/.fleet
+/.zed
+/.nova
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Storage
+/storage/logs/*
+/storage/framework/cache/*
+/storage/framework/sessions/*
+/storage/framework/views/*
+!/storage/framework/.gitignore
+
+# Build artifacts
+/public/hot
+
+# Stack de deploy (contém credenciais)
+/docker/getfy-stack.yml
+
+# Misc
+*.log
+.phpunit.result.cache
+/.phpunit.cache
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
new file mode 100644
index 00000000..191cb89e
--- /dev/null
+++ b/.github/workflows/docker-build.yml
@@ -0,0 +1,48 @@
+name: Build & Push Docker Image
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+env:
+ IMAGE_NAME: impa365/getfyimpa
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 96791ee7..3a60f51a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,4 +25,5 @@ Thumbs.db
/docs
/Docs Gateways
docs
-Docs Gateways
\ No newline at end of file
+Docs Gateways
+/docker/getfy-stack.yml
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 5138139f..d59805da 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,25 +2,46 @@ FROM php:8.2-cli-alpine AS php_base
RUN apk add --no-cache \
git unzip libzip-dev libpng-dev oniguruma-dev \
- mysql-client icu-dev libxml2-dev $PHPIZE_DEPS
+ mysql-client postgresql-client postgresql-dev icu-dev libxml2-dev \
+ freetype-dev libjpeg-turbo-dev $PHPIZE_DEPS
+
+RUN docker-php-ext-configure gd --with-freetype --with-jpeg
RUN pecl install redis \
&& docker-php-ext-enable redis
-RUN docker-php-ext-install pdo_mysql zip exif intl opcache pcntl bcmath
+RUN docker-php-ext-install pdo_mysql pdo_pgsql zip exif intl opcache pcntl bcmath gd
+
+RUN git config --global --add safe.directory /var/www/html
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
+# ---------- Build frontend assets ----------
+FROM node:20-alpine AS frontend
+
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci
+COPY vite.config.js ./
+COPY resources/ ./resources/
+COPY public/ ./public/
+RUN npm run build
+
+# ---------- Final app image ----------
FROM php_base AS app
COPY . .
+COPY --from=frontend /app/public/build public/build
COPY docker/entrypoint.sh /usr/local/bin/getfy-entrypoint
+RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
+
RUN chmod +x /usr/local/bin/getfy-entrypoint \
&& mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views bootstrap/cache .docker \
- && chmod -R 777 storage bootstrap/cache .docker
+ && chmod -R 777 storage bootstrap/cache .docker \
+ && git config --global --add safe.directory /var/www/html
EXPOSE 80
diff --git a/README.md b/README.md
index 04caea9f..b6e77a09 100644
--- a/README.md
+++ b/README.md
@@ -167,8 +167,3 @@ Se o seu painel não suportar `docker-compose.yml`, a alternativa mais simples
- Arquivos em `public/storage` não aparecem: `storage:link` pode falhar em hospedagens sem symlink. Se acontecer, crie manualmente um link/symlink de `public/storage` → `storage/app/public` (ou use um painel que suporte isso).
- Rotinas automáticas não rodam: configure o cron pela URL `/cron?token=...` a cada minuto.
-Se você deseja apoiar o desenvolvimento diretamente:
-| Pix | Chave |
-|---|---|
-| Aleatória | `ce05f7d1-27db-4d46-bca5-0a80c621349a` |
-
diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
index 653acbca..5e736cab 100644
--- a/app/Http/Controllers/DashboardController.php
+++ b/app/Http/Controllers/DashboardController.php
@@ -161,7 +161,7 @@ private function buildGraficoVendas(?int $tenantId, string $period, ?string $sta
if ($isHourly) {
$rows = $query
- ->selectRaw('HOUR(created_at) as hora, SUM(amount) as total')
+ ->selectRaw('EXTRACT(HOUR FROM created_at) as hora, SUM(amount) as total')
->groupBy('hora')
->orderBy('hora')
->get()
@@ -179,7 +179,7 @@ private function buildGraficoVendas(?int $tenantId, string $period, ?string $sta
}
$rows = $query
- ->selectRaw('DATE(created_at) as data, SUM(amount) as total')
+ ->selectRaw('CAST(created_at AS DATE) as data, SUM(amount) as total')
->groupBy('data')
->orderBy('data')
->get();
diff --git a/app/Http/Controllers/EmailTestController.php b/app/Http/Controllers/EmailTestController.php
index 183012f2..4910bfbb 100644
--- a/app/Http/Controllers/EmailTestController.php
+++ b/app/Http/Controllers/EmailTestController.php
@@ -58,7 +58,7 @@ public function sendTest(Request $request)
Mail::purge('smtp');
try {
- $appName = config('getfy.app_name');
+ $appName = Setting::get('app_name', config('getfy.app_name'), $tenantId);
$body = "
Este é um e-mail de teste enviado por {$appName}.
";
Mail::mailer('smtp')->to($validated['test_to'])->send(new \App\Mail\TestEmail('E‑mail de teste - '.$appName, $body));
return response()->json(['success' => true]);
diff --git a/app/Http/Controllers/PanelPwaController.php b/app/Http/Controllers/PanelPwaController.php
index 442d6064..7bb2158a 100644
--- a/app/Http/Controllers/PanelPwaController.php
+++ b/app/Http/Controllers/PanelPwaController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\PanelPushSubscription;
+use App\Models\Setting;
use App\Services\MemberAreaResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -23,8 +24,9 @@ public function manifest(Request $request): JsonResponse
]);
}
- $appName = config('getfy.app_name', 'Getfy');
- $themeColor = config('getfy.theme_primary', '#0ea5e9');
+ $tenantId = auth()->user()?->tenant_id;
+ $appName = Setting::get('app_name', config('getfy.app_name', 'Getfy'), $tenantId);
+ $themeColor = Setting::get('theme_primary', config('getfy.theme_primary', '#0ea5e9'), $tenantId);
$icons = [];
$addIconVariants = function (string $src, string $sizes) use (&$icons): void {
@@ -44,7 +46,7 @@ public function manifest(Request $request): JsonResponse
$addIconVariants($icon512Url, '512x512');
}
if (empty($icons)) {
- $fallbackIcon = (string) config('getfy.app_logo_icon', 'https://cdn.getfy.cloud/collapsed-logo.png');
+ $fallbackIcon = (string) Setting::get('app_logo_icon', config('getfy.app_logo_icon', 'https://cdn.getfy.cloud/collapsed-logo.png'), $tenantId);
$addIconVariants($fallbackIcon, '192x192');
$addIconVariants($fallbackIcon, '512x512');
} elseif ($has512 && ! $has192) {
diff --git a/app/Http/Controllers/RelatoriosController.php b/app/Http/Controllers/RelatoriosController.php
index af806a6e..839b2369 100644
--- a/app/Http/Controllers/RelatoriosController.php
+++ b/app/Http/Controllers/RelatoriosController.php
@@ -232,7 +232,7 @@ private function buildGraficoReceita(?int $tenantId, ?string $start, ?string $en
}
$rows = $query
- ->selectRaw('DATE(created_at) as data, SUM(amount) as total')
+ ->selectRaw('CAST(created_at AS DATE) as data, SUM(amount) as total')
->groupBy('data')
->orderBy('data')
->get();
diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php
index bb97b147..1803f52d 100644
--- a/app/Http/Controllers/SettingsController.php
+++ b/app/Http/Controllers/SettingsController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Setting;
+use App\Services\StorageService;
use App\Support\CheckoutTranslations;
use App\Support\DockerSetupState;
use Illuminate\Http\Request;
@@ -72,6 +73,15 @@ public function index(): Response
'storage_s3_region' => Setting::get('storage_s3_region', 'us-east-1', $tenantId),
'storage_s3_endpoint' => Setting::get('storage_s3_endpoint', '', $tenantId),
'storage_s3_url' => Setting::get('storage_s3_url', '', $tenantId),
+ // Branding
+ 'app_name' => Setting::get('app_name', config('getfy.app_name'), $tenantId),
+ 'app_description' => Setting::get('app_description', '', $tenantId),
+ 'theme_primary' => Setting::get('theme_primary', config('getfy.theme_primary'), $tenantId),
+ 'app_logo' => Setting::get('app_logo', config('getfy.app_logo'), $tenantId),
+ 'app_logo_dark' => Setting::get('app_logo_dark', config('getfy.app_logo_dark'), $tenantId),
+ 'app_logo_icon' => Setting::get('app_logo_icon', config('getfy.app_logo_icon'), $tenantId),
+ 'app_logo_icon_dark' => Setting::get('app_logo_icon_dark', config('getfy.app_logo_icon_dark'), $tenantId),
+ 'app_favicon' => Setting::get('app_favicon', '', $tenantId),
],
]);
}
@@ -112,6 +122,15 @@ public function update(Request $request)
'storage_s3_region' => ['nullable', 'string', 'max:64'],
'storage_s3_endpoint' => ['nullable', 'string', 'max:512'],
'storage_s3_url' => ['nullable', 'string', 'max:512'],
+ // Branding
+ 'app_name' => ['nullable', 'string', 'max:100'],
+ 'app_description' => ['nullable', 'string', 'max:500'],
+ 'theme_primary' => ['nullable', 'string', 'max:20'],
+ 'app_logo' => ['nullable', 'string', 'max:1024'],
+ 'app_logo_dark' => ['nullable', 'string', 'max:1024'],
+ 'app_logo_icon' => ['nullable', 'string', 'max:1024'],
+ 'app_logo_icon_dark' => ['nullable', 'string', 'max:1024'],
+ 'app_favicon' => ['nullable', 'string', 'max:1024'],
]);
$tenantId = auth()->user()->tenant_id;
@@ -122,7 +141,8 @@ public function update(Request $request)
'sendgrid_mail_from_address', 'sendgrid_mail_from_name',
];
$alwaysSetKeys = ['email_provider'];
- $brandingKeys = ['theme_primary', 'app_name', 'app_logo', 'app_logo_dark', 'app_logo_icon', 'app_logo_icon_dark'];
+ $brandingKeys = ['theme_primary', 'app_name', 'app_description', 'app_logo', 'app_logo_dark', 'app_logo_icon', 'app_logo_icon_dark', 'app_favicon'];
+ $isAdmin = auth()->user()->role === 'admin';
// Handle passwords separately (encrypt)
if (array_key_exists('smtp_password', $validated) && $validated['smtp_password'] !== null && $validated['smtp_password'] !== '') {
Setting::set('smtp_password', encrypt($validated['smtp_password']), $tenantId);
@@ -147,7 +167,10 @@ public function update(Request $request)
continue;
}
if (in_array($key, $brandingKeys, true)) {
- continue; // branding hardcoded in config/getfy.php - never save
+ if ($isAdmin) {
+ Setting::set($key, $value ?? '', $tenantId);
+ }
+ continue;
}
if (in_array($key, $alwaysSetKeys, true) || in_array($key, $emailKeys, true)) {
@@ -165,4 +188,33 @@ public function update(Request $request)
return back()->with('success', 'Configurações salvas.');
}
+
+ /**
+ * Upload a branding asset (logo, icon, favicon).
+ */
+ public function uploadBranding(Request $request)
+ {
+ // Apenas o admin (master) pode alterar branding
+ if (auth()->user()->role !== 'admin') {
+ abort(403);
+ }
+
+ $request->validate([
+ 'file' => ['required', 'file', 'mimes:png,jpg,jpeg,gif,svg,webp,ico', 'max:2048'],
+ 'field' => ['required', 'string', 'in:app_logo,app_logo_dark,app_logo_icon,app_logo_icon_dark,app_favicon'],
+ ]);
+
+ $tenantId = auth()->user()->tenant_id;
+ $storage = app(StorageService::class);
+ $path = $storage->putFile('branding', $request->file('file'));
+
+ if (! $path) {
+ return response()->json(['error' => 'Falha ao fazer upload do arquivo.'], 500);
+ }
+
+ $url = $storage->url($path);
+ Setting::set($request->input('field'), $url, $tenantId);
+
+ return response()->json(['url' => $url]);
+ }
}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 57094ecc..dd1c7a3e 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -5,6 +5,7 @@
use App\Models\MemberNotification;
use App\Models\MemberPushSubscription;
use App\Models\PanelNotification;
+use App\Models\Setting;
use App\Plugins\PluginRegistry;
use App\Services\SalesAchievementsService;
use App\Services\StorageService;
@@ -45,12 +46,14 @@ public function share(Request $request): array
$tenantId = $user?->tenant_id;
$appSettings = $user ? [
- 'app_name' => config('getfy.app_name'),
- 'theme_primary' => config('getfy.theme_primary'),
- 'app_logo' => config('getfy.app_logo'),
- 'app_logo_dark' => config('getfy.app_logo_dark'),
- 'app_logo_icon' => config('getfy.app_logo_icon'),
- 'app_logo_icon_dark' => config('getfy.app_logo_icon_dark'),
+ 'app_name' => Setting::get('app_name', config('getfy.app_name'), $tenantId),
+ 'theme_primary' => Setting::get('theme_primary', config('getfy.theme_primary'), $tenantId),
+ 'app_logo' => Setting::get('app_logo', config('getfy.app_logo'), $tenantId),
+ 'app_logo_dark' => Setting::get('app_logo_dark', config('getfy.app_logo_dark'), $tenantId),
+ 'app_logo_icon' => Setting::get('app_logo_icon', config('getfy.app_logo_icon'), $tenantId),
+ 'app_logo_icon_dark' => Setting::get('app_logo_icon_dark', config('getfy.app_logo_icon_dark'), $tenantId),
+ 'app_favicon' => Setting::get('app_favicon', '', $tenantId),
+ 'app_description' => Setting::get('app_description', '', $tenantId),
] : null;
$pageTitle = $this->pageTitleForRoute($request->route()?->getName());
diff --git a/config/getfy.php b/config/getfy.php
index 4690f667..25c04516 100644
--- a/config/getfy.php
+++ b/config/getfy.php
@@ -17,10 +17,10 @@
'vapid_public' => env('PWA_VAPID_PUBLIC', null),
'vapid_private' => env('PWA_VAPID_PRIVATE', null),
],
- 'app_name' => 'Getfy',
- 'theme_primary' => '#00cc00',
- 'app_logo' => 'https://cdn.getfy.cloud/logo-white.png',
- 'app_logo_dark' => 'https://cdn.getfy.cloud/logo-dark.png',
- 'app_logo_icon' => 'https://cdn.getfy.cloud/collapsed-logo.png',
- 'app_logo_icon_dark' => 'https://cdn.getfy.cloud/collapsed-logo.png',
+ 'app_name' => env('GETFY_APP_NAME', 'Getfy'),
+ 'theme_primary' => env('GETFY_THEME_PRIMARY', '#00cc00'),
+ 'app_logo' => env('GETFY_LOGO', 'https://cdn.getfy.cloud/logo-white.png'),
+ 'app_logo_dark' => env('GETFY_LOGO_DARK', 'https://cdn.getfy.cloud/logo-dark.png'),
+ 'app_logo_icon' => env('GETFY_LOGO_ICON', 'https://cdn.getfy.cloud/collapsed-logo.png'),
+ 'app_logo_icon_dark' => env('GETFY_LOGO_ICON_DARK', 'https://cdn.getfy.cloud/collapsed-logo.png'),
];
diff --git a/database/migrations/2025_02_26_000001_change_products_id_to_uuid.php b/database/migrations/2025_02_26_000001_change_products_id_to_uuid.php
index 69b2a052..77ca3a7a 100644
--- a/database/migrations/2025_02_26_000001_change_products_id_to_uuid.php
+++ b/database/migrations/2025_02_26_000001_change_products_id_to_uuid.php
@@ -41,13 +41,19 @@ public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
- if ($driver !== 'mysql' && $driver !== 'mariadb') {
+ if ($driver === 'mysql' || $driver === 'mariadb') {
+ DB::transaction(function () {
+ $this->runUpMySQL();
+ });
return;
}
- DB::transaction(function () {
- $this->runUpMySQL();
- });
+ if ($driver === 'pgsql') {
+ DB::transaction(function () {
+ $this->runUpPostgreSQL();
+ });
+ return;
+ }
}
private function runUpMySQL(): void
@@ -205,6 +211,87 @@ private function restoreIndexes(string $table, array $definitions): void
}
}
+ /**
+ * PostgreSQL: convert products.id from bigint to varchar(36) UUID.
+ * Uses ALTER COLUMN TYPE with USING cast (PG supports transactional DDL).
+ */
+ private function runUpPostgreSQL(): void
+ {
+ $col = DB::selectOne(
+ "SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'id'"
+ );
+ if ($col && in_array($col->data_type, ['character', 'character varying'])) {
+ return;
+ }
+
+ // Build UUID mapping for existing products (empty on fresh installs)
+ $mapping = [];
+ foreach (DB::table('products')->pluck('id') as $oldId) {
+ $mapping[$oldId] = (string) Str::uuid();
+ }
+
+ // Drop all FK constraints
+ foreach (self::PRODUCT_FK_TABLES as $table => $columns) {
+ if (! Schema::hasTable($table)) {
+ continue;
+ }
+ foreach ($columns as $column) {
+ DB::statement("ALTER TABLE \"{$table}\" DROP CONSTRAINT IF EXISTS \"{$table}_{$column}_foreign\"");
+ }
+ }
+
+ // Convert products.id from bigint to varchar(36)
+ DB::statement('ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_pkey"');
+ DB::statement('ALTER TABLE "products" ALTER COLUMN "id" DROP DEFAULT');
+ DB::statement('ALTER TABLE "products" ALTER COLUMN "id" TYPE VARCHAR(36) USING "id"::text');
+
+ // Replace old bigint IDs with UUIDs
+ foreach ($mapping as $oldId => $newUuid) {
+ DB::table('products')->where('id', (string) $oldId)->update(['id' => $newUuid]);
+ }
+
+ DB::statement('ALTER TABLE "products" ALTER COLUMN "id" SET NOT NULL');
+ DB::statement('ALTER TABLE "products" ADD PRIMARY KEY ("id")');
+ DB::statement('DROP SEQUENCE IF EXISTS "products_id_seq"');
+
+ // Convert FK columns to varchar(36) and update with UUIDs
+ foreach (self::PRODUCT_FK_TABLES as $table => $columns) {
+ if (! Schema::hasTable($table)) {
+ continue;
+ }
+ foreach ($columns as $column) {
+ if (! Schema::hasColumn($table, $column)) {
+ continue;
+ }
+ DB::statement("ALTER TABLE \"{$table}\" ALTER COLUMN \"{$column}\" TYPE VARCHAR(36) USING \"{$column}\"::text");
+ foreach ($mapping as $oldId => $newUuid) {
+ DB::table($table)->where($column, (string) $oldId)->update([$column => $newUuid]);
+ }
+ }
+ }
+
+ // Recreate FK constraints
+ foreach (self::PRODUCT_FK_TABLES as $table => $columns) {
+ if (! Schema::hasTable($table)) {
+ continue;
+ }
+ foreach ($columns as $column) {
+ if (! Schema::hasColumn($table, $column)) {
+ continue;
+ }
+ Schema::table($table, function (Blueprint $t) use ($table, $column) {
+ $isNullable = $column === 'related_product_id' || (isset(self::NULLABLE_PRODUCT_COLUMNS[$table]) && in_array($column, self::NULLABLE_PRODUCT_COLUMNS[$table], true));
+ $fk = $t->foreign($column)->references('id')->on('products');
+ if ($isNullable) {
+ $fk->nullOnDelete();
+ } else {
+ $fk->cascadeOnDelete();
+ }
+ });
+ }
+ }
+ }
+
public function down(): void
{
throw new \RuntimeException('Rollback of products UUID migration is not supported. Restore from backup if needed.');
diff --git a/database/migrations/2026_02_25_000001_fix_product_offers_subscription_plans_product_id_uuid.php b/database/migrations/2026_02_25_000001_fix_product_offers_subscription_plans_product_id_uuid.php
index e818b1ee..916865a7 100644
--- a/database/migrations/2026_02_25_000001_fix_product_offers_subscription_plans_product_id_uuid.php
+++ b/database/migrations/2026_02_25_000001_fix_product_offers_subscription_plans_product_id_uuid.php
@@ -15,6 +15,23 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ foreach (['product_offers', 'subscription_plans'] as $table) {
+ if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'product_id')) {
+ continue;
+ }
+ $col = DB::selectOne("SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND column_name = 'product_id'", [$table]);
+ if (! $col || in_array($col->data_type, ['character', 'character varying'])) {
+ continue;
+ }
+ DB::statement("ALTER TABLE \"{$table}\" DROP CONSTRAINT IF EXISTS \"{$table}_product_id_foreign\"");
+ DB::statement("ALTER TABLE \"{$table}\" ALTER COLUMN \"product_id\" TYPE VARCHAR(36) USING \"product_id\"::text");
+ Schema::table($table, fn (Blueprint $t) => $t->foreign('product_id')->references('id')->on('products')->cascadeOnDelete());
+ }
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_02_26_000001_make_checkout_slug_nullable_on_offers_and_plans.php b/database/migrations/2026_02_26_000001_make_checkout_slug_nullable_on_offers_and_plans.php
index e94bf482..76ba8db8 100644
--- a/database/migrations/2026_02_26_000001_make_checkout_slug_nullable_on_offers_and_plans.php
+++ b/database/migrations/2026_02_26_000001_make_checkout_slug_nullable_on_offers_and_plans.php
@@ -9,6 +9,13 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ DB::statement('ALTER TABLE "product_offers" ALTER COLUMN "checkout_slug" DROP NOT NULL');
+ DB::statement('ALTER TABLE "subscription_plans" ALTER COLUMN "checkout_slug" DROP NOT NULL');
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
@@ -20,6 +27,13 @@ public function up(): void
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ DB::statement('ALTER TABLE "product_offers" ALTER COLUMN "checkout_slug" SET NOT NULL');
+ DB::statement('ALTER TABLE "subscription_plans" ALTER COLUMN "checkout_slug" SET NOT NULL');
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_02_27_000001_fix_member_tables_product_id_uuid.php b/database/migrations/2026_02_27_000001_fix_member_tables_product_id_uuid.php
index 02ce525c..b1b8552b 100644
--- a/database/migrations/2026_02_27_000001_fix_member_tables_product_id_uuid.php
+++ b/database/migrations/2026_02_27_000001_fix_member_tables_product_id_uuid.php
@@ -38,6 +38,35 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ foreach (self::TABLES as $table => $columns) {
+ if (! Schema::hasTable($table)) {
+ continue;
+ }
+ foreach ($columns as $column) {
+ if (! Schema::hasColumn($table, $column)) {
+ continue;
+ }
+ $col = DB::selectOne("SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND column_name = ?", [$table, $column]);
+ if (! $col || in_array($col->data_type, ['character', 'character varying'])) {
+ continue;
+ }
+ DB::statement("ALTER TABLE \"{$table}\" DROP CONSTRAINT IF EXISTS \"{$table}_{$column}_foreign\"");
+ DB::statement("ALTER TABLE \"{$table}\" ALTER COLUMN \"{$column}\" TYPE VARCHAR(36) USING \"{$column}\"::text");
+ $nullable = isset(self::NULLABLE_COLUMNS[$table]) && in_array($column, self::NULLABLE_COLUMNS[$table], true);
+ $orphans = DB::table($table)->whereNotNull($column)->whereNotIn($column, DB::table('products')->select('id'))->count();
+ if ($orphans === 0) {
+ Schema::table($table, function (Blueprint $t) use ($column, $nullable) {
+ $fk = $t->foreign($column)->references('id')->on('products');
+ ($nullable || $column === 'related_product_id') ? $fk->nullOnDelete() : $fk->cascadeOnDelete();
+ });
+ }
+ }
+ }
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_02_27_012305_fix_checkout_sessions_product_id_uuid.php b/database/migrations/2026_02_27_012305_fix_checkout_sessions_product_id_uuid.php
index f5bf9927..1b74dcad 100644
--- a/database/migrations/2026_02_27_012305_fix_checkout_sessions_product_id_uuid.php
+++ b/database/migrations/2026_02_27_012305_fix_checkout_sessions_product_id_uuid.php
@@ -15,6 +15,21 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ if (! Schema::hasTable('checkout_sessions') || ! Schema::hasColumn('checkout_sessions', 'product_id')) {
+ return;
+ }
+ $col = DB::selectOne("SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'checkout_sessions' AND column_name = 'product_id'");
+ if (! $col || in_array($col->data_type, ['character', 'character varying'])) {
+ return;
+ }
+ DB::statement('ALTER TABLE "checkout_sessions" DROP CONSTRAINT IF EXISTS "checkout_sessions_product_id_foreign"');
+ DB::statement('ALTER TABLE "checkout_sessions" ALTER COLUMN "product_id" TYPE VARCHAR(36) USING "product_id"::text');
+ Schema::table('checkout_sessions', fn (Blueprint $t) => $t->foreign('product_id')->references('id')->on('products')->cascadeOnDelete());
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_03_01_000001_fix_product_user_product_id_uuid.php b/database/migrations/2026_03_01_000001_fix_product_user_product_id_uuid.php
index cf03f711..fb41c0c8 100644
--- a/database/migrations/2026_03_01_000001_fix_product_user_product_id_uuid.php
+++ b/database/migrations/2026_03_01_000001_fix_product_user_product_id_uuid.php
@@ -15,6 +15,26 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ if (! Schema::hasTable('product_user') || ! Schema::hasColumn('product_user', 'product_id')) {
+ return;
+ }
+ $col = DB::selectOne("SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_user' AND column_name = 'product_id'");
+ if (! $col || in_array($col->data_type, ['character', 'character varying'])) {
+ return;
+ }
+ DB::statement('ALTER TABLE "product_user" DROP CONSTRAINT IF EXISTS "product_user_product_id_foreign"');
+ DB::statement('ALTER TABLE "product_user" DROP CONSTRAINT IF EXISTS "product_user_product_id_user_id_unique"');
+ DB::statement('ALTER TABLE "product_user" ALTER COLUMN "product_id" TYPE VARCHAR(36) USING "product_id"::text');
+ DB::table('product_user')->whereNotIn('product_id', DB::table('products')->select('id'))->delete();
+ Schema::table('product_user', function (Blueprint $t) {
+ $t->foreign('product_id')->references('id')->on('products')->cascadeOnDelete();
+ $t->unique(['product_id', 'user_id']);
+ });
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_03_01_000002_fix_subscriptions_product_id_uuid.php b/database/migrations/2026_03_01_000002_fix_subscriptions_product_id_uuid.php
index fd0830fb..7eb19feb 100644
--- a/database/migrations/2026_03_01_000002_fix_subscriptions_product_id_uuid.php
+++ b/database/migrations/2026_03_01_000002_fix_subscriptions_product_id_uuid.php
@@ -15,6 +15,22 @@
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
+
+ if ($driver === 'pgsql') {
+ if (! Schema::hasTable('subscriptions') || ! Schema::hasColumn('subscriptions', 'product_id')) {
+ return;
+ }
+ $col = DB::selectOne("SELECT data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'subscriptions' AND column_name = 'product_id'");
+ if (! $col || in_array($col->data_type, ['character', 'character varying'])) {
+ return;
+ }
+ DB::statement('ALTER TABLE "subscriptions" DROP CONSTRAINT IF EXISTS "subscriptions_product_id_foreign"');
+ DB::statement('ALTER TABLE "subscriptions" ALTER COLUMN "product_id" TYPE VARCHAR(36) USING "product_id"::text');
+ DB::table('subscriptions')->whereNotIn('product_id', DB::table('products')->select('id'))->delete();
+ Schema::table('subscriptions', fn (Blueprint $t) => $t->foreign('product_id')->references('id')->on('products')->cascadeOnDelete());
+ return;
+ }
+
if ($driver !== 'mysql' && $driver !== 'mariadb') {
return;
}
diff --git a/database/migrations/2026_03_13_000001_create_member_notifications_table.php b/database/migrations/2026_03_13_000001_create_member_notifications_table.php
index c348bc3a..ffd761b3 100644
--- a/database/migrations/2026_03_13_000001_create_member_notifications_table.php
+++ b/database/migrations/2026_03_13_000001_create_member_notifications_table.php
@@ -14,7 +14,7 @@ public function up(): void
$table->engine = 'InnoDB';
$table->id();
// products.id é UUID (CHAR 36) nesta base; users.id é bigint unsigned
- $table->uuid('product_id');
+ $table->string('product_id', 36);
$table->unsignedBigInteger('user_id');
$table->string('type', 64)->index();
$table->string('title');
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index c34accd5..12c8638f 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -49,10 +49,10 @@ $vars = [
"APP_AUTO_MIGRATE" => getenv("APP_AUTO_MIGRATE") ?: "false",
"CRON_SECRET" => $cronSecret ?: null,
"DB_CONNECTION" => getenv("DB_CONNECTION") ?: "mysql",
- "DB_HOST" => getenv("DB_HOST") ?: "mysql",
- "DB_PORT" => getenv("DB_PORT") ?: "3306",
+ "DB_HOST" => getenv("DB_HOST") ?: ((getenv("DB_CONNECTION") ?: "mysql") === "pgsql" ? "postgres" : "mysql"),
+ "DB_PORT" => getenv("DB_PORT") ?: ((getenv("DB_CONNECTION") ?: "mysql") === "pgsql" ? "5432" : "3306"),
"DB_DATABASE" => getenv("DB_DATABASE") ?: "getfy",
- "DB_USERNAME" => getenv("DB_USERNAME") ?: "getfy",
+ "DB_USERNAME" => getenv("DB_USERNAME") ?: ((getenv("DB_CONNECTION") ?: "mysql") === "pgsql" ? "postgres" : "getfy"),
"DB_PASSWORD" => getenv("DB_PASSWORD") ?: "getfy",
"CACHE_STORE" => getenv("CACHE_STORE") ?: "redis",
"QUEUE_CONNECTION" => getenv("QUEUE_CONNECTION") ?: "redis",
@@ -84,15 +84,30 @@ foreach ($vars as $key => $value) {
file_put_contents($envFile, $content);
'
-DB_HOST="${DB_HOST:-mysql}"
-DB_PORT="${DB_PORT:-3306}"
-DB_DATABASE="${DB_DATABASE:-getfy}"
-DB_USERNAME="${DB_USERNAME:-${MYSQL_USER:-getfy}}"
-DB_PASSWORD="${DB_PASSWORD:-${MYSQL_PASSWORD:-getfy}}"
+DB_CONNECTION="${DB_CONNECTION:-mysql}"
+if [ "$DB_CONNECTION" = "pgsql" ]; then
+ DB_HOST="${DB_HOST:-postgres}"
+ DB_PORT="${DB_PORT:-5432}"
+ DB_DATABASE="${DB_DATABASE:-getfy}"
+ DB_USERNAME="${DB_USERNAME:-postgres}"
+ DB_PASSWORD="${DB_PASSWORD:-getfy}"
+ DB_DSN="pgsql:host=${DB_HOST};port=${DB_PORT};dbname=${DB_DATABASE}"
+ DB_LABEL="PostgreSQL"
+else
+ DB_HOST="${DB_HOST:-mysql}"
+ DB_PORT="${DB_PORT:-3306}"
+ DB_DATABASE="${DB_DATABASE:-getfy}"
+ DB_USERNAME="${DB_USERNAME:-${MYSQL_USER:-getfy}}"
+ DB_PASSWORD="${DB_PASSWORD:-${MYSQL_PASSWORD:-getfy}}"
+ DB_DSN="mysql:host=${DB_HOST};port=${DB_PORT};dbname=${DB_DATABASE}"
+ DB_LABEL="MySQL"
+fi
+
+echo "Aguardando ${DB_LABEL} em ${DB_HOST}:${DB_PORT}..."
DB_OK=0
for i in $(seq 1 60); do
- if php -r "try { new PDO('mysql:host=${DB_HOST};port=${DB_PORT};dbname=${DB_DATABASE}', '${DB_USERNAME}', '${DB_PASSWORD}', [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); } catch (Throwable \$e) { exit(1); }" >/dev/null 2>&1; then
+ if php -r "try { new PDO('${DB_DSN}', '${DB_USERNAME}', '${DB_PASSWORD}', [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); } catch (Throwable \$e) { exit(1); }" >/dev/null 2>&1; then
DB_OK=1
break
fi
@@ -100,7 +115,7 @@ for i in $(seq 1 60); do
done
if [ "$DB_OK" -ne 1 ]; then
- echo "MySQL indisponível. Verifique DB_HOST/DB_PORT e o serviço mysql no compose."
+ echo "${DB_LABEL} indisponível. Verifique DB_HOST/DB_PORT e o serviço de banco no compose."
exit 1
fi
diff --git a/resources/js/Pages/Settings/Index.vue b/resources/js/Pages/Settings/Index.vue
index 51a9c851..51962674 100644
--- a/resources/js/Pages/Settings/Index.vue
+++ b/resources/js/Pages/Settings/Index.vue
@@ -1,11 +1,11 @@
- {{ config('app.name', 'Getfy') }}
+ {{ $brandAppName }}
@unless($skipPanelPwa)
-
+
-
+
diff --git a/routes/web.php b/routes/web.php
index c5e09de8..df119062 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -214,6 +214,7 @@
Route::get('/relatorios', [\App\Http\Controllers\RelatoriosController::class, 'index'])->name('relatorios.index');
Route::get('/configuracoes', [\App\Http\Controllers\SettingsController::class, 'index'])->name('settings.index');
Route::put('/configuracoes', [\App\Http\Controllers\SettingsController::class, 'update'])->name('settings.update');
+ Route::post('/configuracoes/branding/upload', [\App\Http\Controllers\SettingsController::class, 'uploadBranding'])->name('settings.branding.upload');
Route::post('/configuracoes/email/test', [\App\Http\Controllers\EmailTestController::class, 'test'])->name('settings.email.test');
Route::post('/configuracoes/email/connection-test', [\App\Http\Controllers\EmailTestController::class, 'connectionTest'])->name('settings.email.connection-test');
Route::post('/configuracoes/email/send-test', [\App\Http\Controllers\EmailTestController::class, 'sendTest'])->name('settings.email.send-test');
From bcfff9d182480f36fc67f0eddb443b1d2ea61800 Mon Sep 17 00:00:00 2001
From: Impa365 <91766929+impa365@users.noreply.github.com>
Date: Thu, 12 Mar 2026 23:32:35 -0300
Subject: [PATCH 2/2] =?UTF-8?q?Drip=20content=20(libera=C3=A7=C3=A3o=20pro?=
=?UTF-8?q?gressiva=20de=20m=C3=B3dulos)=20+=20fix=20p=C3=A1gina=20docs/ap?=
=?UTF-8?q?i-pagamentos?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. Conteúdo progressivo (Drip Content)
Implementa sistema completo de liberação progressiva de módulos na área de membros. O infoprodutor define quantos dias após a compra cada módulo será disponibilizado ao aluno, e pode desbloquear manualmente para alunos específicos.
Backend:
Nova migration: coluna release_after_days em member_modules + tabela member_content_unlocks para desbloqueios manuais
Novo model MemberContentUnlock com relacionamentos user, module e unlockedByUser
MemberModule ganha método dripUnlocksAt($userId) que calcula a data de liberação com base na data do pedido + dias configurados, respeitando desbloqueios manuais
MemberAreaAppController: módulos bloqueados retornam is_locked: true, unlocks_at e lessons: []; acesso direto via moduleContent() e lesson() é bloqueado com redirect
MemberBuilderController: storeModule e updateModule aceitam release_after_days; 3 novos endpoints para gerenciar desbloqueios manuais (listar, desbloquear, re-bloquear)
3 novas rotas: GET/POST/DELETE .../modules/{module}/unlock[s]
Frontend — Painel do infoprodutor (MemberBuilder):
Campo inline "Liberar após X dias" em cada módulo (seções do tipo courses)
Botão "Desbloqueios" abre modal com select de alunos → desbloquear; lista de desbloqueios existentes com opção de remover
Frontend — Área do aluno:
Show.vue, Modulos.vue, ModuleContent.vue: módulos bloqueados exibem cadeado (ícone Lock) com contagem regressiva ("Disponível em X dias", "Amanhã", "Em breve")
Módulos bloqueados não são clicáveis e aparecem com opacidade reduzida + thumbnail escurecida
Sidebar do player também indica módulos bloqueados
2. Fix página /docs/api-pagamentos
O .gitignore tinha a linha docs (sem / inicial) na linha 27, que funcionava como padrão global e ignorava qualquer pasta chamada docs em qualquer nível — incluindo Docs e docs. Isso impedia que os arquivos da documentação da API fossem commitados e, consequentemente, incluídos no build Docker.
Removidas linhas 27-28 (docs e Docs Gateways) do .gitignore
As linhas /docs e /Docs Gateways (com / inicial, só raiz) já existiam e foram mantidas
---
.gitignore | 2 -
app/Http/Controllers/CheckoutController.php | 12 +-
.../Controllers/MemberAreaAppController.php | 80 +++--
.../Controllers/MemberBuilderController.php | 84 +++++
.../Controllers/MeusPedidosController.php | 299 ++++++++++++++++++
app/Http/Controllers/UpsellController.php | 10 +-
app/Http/Controllers/VendasController.php | 67 +++-
app/Http/Middleware/HandleInertiaRequests.php | 2 +
app/Models/MemberContentUnlock.php | 26 ++
app/Models/MemberModule.php | 49 ++-
app/Models/Order.php | 2 +-
...000_add_payment_method_to_orders_table.php | 22 ++
...001_add_drip_content_to_member_modules.php | 34 ++
resources/js/Pages/Checkout/ThankYou.vue | 7 +-
resources/js/Pages/MemberArea/Index.vue | 16 +-
resources/js/Pages/MemberArea/MeusPedidos.vue | 216 +++++++++++++
.../js/Pages/MemberAreaApp/ModuleContent.vue | 57 +++-
resources/js/Pages/MemberAreaApp/Modulos.vue | 27 +-
resources/js/Pages/MemberAreaApp/Show.vue | 57 +++-
.../js/Pages/Produtos/MemberBuilder/Index.vue | 126 ++++++++
resources/js/Pages/Vendas/Index.vue | 2 +-
.../components/vendas/VendaDetailSidebar.vue | 6 +-
routes/web.php | 7 +
23 files changed, 1133 insertions(+), 77 deletions(-)
create mode 100644 app/Http/Controllers/MeusPedidosController.php
create mode 100644 app/Models/MemberContentUnlock.php
create mode 100644 database/migrations/2026_03_12_200000_add_payment_method_to_orders_table.php
create mode 100644 database/migrations/2026_03_15_000001_add_drip_content_to_member_modules.php
create mode 100644 resources/js/Pages/MemberArea/MeusPedidos.vue
diff --git a/.gitignore b/.gitignore
index 3a60f51a..2a4d21cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,4 @@ Homestead.yaml
Thumbs.db
/docs
/Docs Gateways
-docs
-Docs Gateways
/docker/getfy-stack.yml
\ No newline at end of file
diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php
index be8ccc68..845e4c41 100644
--- a/app/Http/Controllers/CheckoutController.php
+++ b/app/Http/Controllers/CheckoutController.php
@@ -621,6 +621,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => 'pix',
]));
$order->load('orderItems');
event(new OrderPending($order));
@@ -716,6 +717,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => 'pix',
]));
$order->load('orderItems');
event(new OrderPending($order));
@@ -822,6 +824,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => 'pix',
]));
$order->load('orderItems');
event(new OrderPending($order));
@@ -960,6 +963,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => 'card',
]));
$order->load('orderItems');
event(new OrderPending($order));
@@ -1043,7 +1047,8 @@ public function process(Request $request): RedirectResponse|JsonResponse
if (! empty($customRedirect) && is_string($customRedirect)) {
$redirectUrl = $customRedirect;
} else {
- $next = ($order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
+ $hasMemberArea = $order->product && in_array($order->product->type, [Product::TYPE_AREA_MEMBROS, Product::TYPE_APLICATIVO], true);
+ $next = ($hasMemberArea && $order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
$redirectUrl = route('checkout.thank-you', ['order_id' => $order->id, 'next' => $next]);
}
}
@@ -1090,6 +1095,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => 'boleto',
]));
$order->load('orderItems');
event(new OrderPending($order));
@@ -1168,6 +1174,7 @@ public function process(Request $request): RedirectResponse|JsonResponse
$order = $createOrderAndItems(array_merge($orderPayload, [
'status' => 'completed',
'gateway' => 'manual',
+ 'payment_method' => 'manual',
]));
$updateCheckoutSession($order);
$order->load('orderItems');
@@ -1400,7 +1407,8 @@ public function orderStatus(Request $request): JsonResponse
if (! empty($customRedirect) && is_string($customRedirect)) {
$redirectUrl = $customRedirect;
} else {
- $next = ($order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
+ $hasMemberArea = $order->product && in_array($order->product->type, [Product::TYPE_AREA_MEMBROS, Product::TYPE_APLICATIVO], true);
+ $next = ($hasMemberArea && $order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
$redirectUrl = route('checkout.thank-you', ['order_id' => $order->id, 'next' => $next]);
}
}
diff --git a/app/Http/Controllers/MemberAreaAppController.php b/app/Http/Controllers/MemberAreaAppController.php
index c099318d..6682f685 100644
--- a/app/Http/Controllers/MemberAreaAppController.php
+++ b/app/Http/Controllers/MemberAreaAppController.php
@@ -89,19 +89,25 @@ public function modulos(Request $request, string $slug): Response
'id' => $s->id,
'title' => $s->title,
'cover_mode' => $s->cover_mode ?? 'vertical',
- 'modules' => $s->modules->map(fn ($m) => [
- 'id' => $m->id,
- 'title' => $m->title,
- 'thumbnail' => $m->thumbnail,
- 'show_title_on_cover' => $m->show_title_on_cover ?? true,
- 'lessons' => $m->lessons->map(fn (MemberLesson $l) => [
- 'id' => $l->id,
- 'title' => $l->title,
- 'type' => $l->type,
- 'duration_seconds' => $l->duration_seconds,
- 'is_completed' => $this->isLessonCompleted($user->id, $l->id),
- ])->values()->all(),
- ])->values()->all(),
+ 'modules' => $s->modules->map(function ($m) use ($user) {
+ $unlocksAt = $m->dripUnlocksAt($user->id);
+ $isLocked = $unlocksAt !== null;
+ return [
+ 'id' => $m->id,
+ 'title' => $m->title,
+ 'thumbnail' => $m->thumbnail,
+ 'show_title_on_cover' => $m->show_title_on_cover ?? true,
+ 'is_locked' => $isLocked,
+ 'unlocks_at' => $unlocksAt?->toIso8601String(),
+ 'lessons' => $isLocked ? [] : $m->lessons->map(fn (MemberLesson $l) => [
+ 'id' => $l->id,
+ 'title' => $l->title,
+ 'type' => $l->type,
+ 'duration_seconds' => $l->duration_seconds,
+ 'is_completed' => $this->isLessonCompleted($user->id, $l->id),
+ ])->values()->all(),
+ ];
+ })->values()->all(),
])->values()->all(),
'base_url' => $this->baseUrlForRequest($product, $request),
'slug' => $slug,
@@ -116,6 +122,16 @@ public function moduleContent(Request $request, string $slug, MemberModule $modu
abort(404);
}
$user = $request->user();
+
+ // Drip content: bloqueia acesso se módulo ainda não liberado
+ $unlocksAt = $module->dripUnlocksAt($user->id);
+ if ($unlocksAt !== null) {
+ if ($request->header('X-Inertia')) {
+ return redirect()->back()->with('error', 'Este módulo será liberado em '.$unlocksAt->format('d/m/Y').'.');
+ }
+ abort(403, 'Conteúdo ainda não liberado.');
+ }
+
$module->load(['section', 'lessons' => fn ($q) => $q->orderBy('position')]);
$lessons = $module->lessons->map(fn (MemberLesson $l) => [
'id' => $l->id,
@@ -160,12 +176,17 @@ public function moduleContent(Request $request, string $slug, MemberModule $modu
'id' => $s->id,
'title' => $s->title,
'cover_mode' => $s->cover_mode ?? 'vertical',
- 'modules' => $s->modules->map(fn ($m) => [
- 'id' => $m->id,
- 'title' => $m->title,
- 'thumbnail' => $m->thumbnail,
- 'show_title_on_cover' => $m->show_title_on_cover ?? true,
- ])->values()->all(),
+ 'modules' => $s->modules->map(function ($m) use ($user) {
+ $unlocksAt = $m->dripUnlocksAt($user->id);
+ return [
+ 'id' => $m->id,
+ 'title' => $m->title,
+ 'thumbnail' => $m->thumbnail,
+ 'show_title_on_cover' => $m->show_title_on_cover ?? true,
+ 'is_locked' => $unlocksAt !== null,
+ 'unlocks_at' => $unlocksAt?->toIso8601String(),
+ ];
+ })->values()->all(),
])->values()->all();
$config = $product->member_area_config;
@@ -221,6 +242,19 @@ public function lesson(Request $request, string $slug, MemberLesson $lesson): Re
abort(404);
}
$user = $request->user();
+
+ // Drip content: bloqueia acesso se módulo da aula ainda não liberado
+ $lesson->loadMissing('module');
+ if ($lesson->module) {
+ $unlocksAt = $lesson->module->dripUnlocksAt($user->id);
+ if ($unlocksAt !== null) {
+ if ($request->header('X-Inertia')) {
+ return redirect()->back()->with('error', 'Este conteúdo será liberado em '.$unlocksAt->format('d/m/Y').'.');
+ }
+ abort(403, 'Conteúdo ainda não liberado.');
+ }
+ }
+
$this->progressService->ensureLessonStarted($lesson, $user);
$lesson->load('module.section');
@@ -793,12 +827,18 @@ private function mapModuleForMemberArea(MemberModule $m, MemberSection $s, Produ
$sectionType = $s->section_type ?? 'courses';
if ($sectionType === 'courses') {
+ $unlocksAt = $m->dripUnlocksAt($user->id);
+ $isLocked = $unlocksAt !== null;
+
return [
'id' => $m->id,
'title' => $m->title,
'thumbnail' => $m->thumbnail,
'show_title_on_cover' => $m->show_title_on_cover ?? true,
- 'lessons' => $m->lessons->map(fn (MemberLesson $l) => [
+ 'is_locked' => $isLocked,
+ 'unlocks_at' => $unlocksAt?->toIso8601String(),
+ 'release_after_days' => $m->release_after_days,
+ 'lessons' => $isLocked ? [] : $m->lessons->map(fn (MemberLesson $l) => [
'id' => $l->id,
'title' => $l->title,
'type' => $l->type,
diff --git a/app/Http/Controllers/MemberBuilderController.php b/app/Http/Controllers/MemberBuilderController.php
index fe5e9cfe..603bb855 100644
--- a/app/Http/Controllers/MemberBuilderController.php
+++ b/app/Http/Controllers/MemberBuilderController.php
@@ -6,6 +6,7 @@
use App\Models\MemberComment;
use App\Models\MemberCommunityPage;
use App\Models\MemberCommunityPost;
+use App\Models\MemberContentUnlock;
use App\Models\MemberInternalProduct;
use App\Models\MemberLesson;
use App\Models\MemberModule;
@@ -109,6 +110,7 @@ public function index(Product $produto): View|RedirectResponse
'position' => $m->position,
'thumbnail' => $m->thumbnail,
'show_title_on_cover' => $m->show_title_on_cover ?? true,
+ 'release_after_days' => $m->release_after_days ?? 0,
'lessons' => $m->lessons->map(fn (MemberLesson $l) => [
'id' => $l->id,
'title' => $l->title,
@@ -474,6 +476,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'show_title_on_cover' => ['nullable', 'boolean'],
+ 'release_after_days' => ['nullable', 'integer', 'min:0', 'max:365'],
]);
$max = MemberModule::where('member_section_id', $section->id)->max('position') ?? 0;
$module = MemberModule::create([
@@ -482,6 +485,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s
'title' => $validated['title'],
'position' => $max + 1,
'show_title_on_cover' => $validated['show_title_on_cover'] ?? true,
+ 'release_after_days' => $validated['release_after_days'] ?? 0,
]);
} elseif ($sectionType === 'products') {
$validated = $request->validate([
@@ -539,6 +543,7 @@ public function storeModule(Request $request, Product $produto, MemberSection $s
'position' => $module->position,
'thumbnail' => $module->thumbnail,
'show_title_on_cover' => $module->show_title_on_cover ?? true,
+ 'release_after_days' => $module->release_after_days ?? 0,
'lessons' => $module->relationLoaded('lessons') ? $module->lessons->map(fn (MemberLesson $l) => [
'id' => $l->id,
'title' => $l->title,
@@ -584,6 +589,7 @@ public function updateModule(Request $request, Product $produto, MemberModule $m
'position' => ['sometimes', 'integer', 'min:0'],
'thumbnail' => ['nullable', 'string', 'max:500'],
'show_title_on_cover' => ['sometimes', 'boolean'],
+ 'release_after_days' => ['sometimes', 'integer', 'min:0', 'max:365'],
]);
} elseif ($sectionType === 'products') {
$validated = $request->validate([
@@ -1228,4 +1234,82 @@ private function createVapidKeysViaOpensslCli(): ?array
return null;
}
}
+
+ // ─── Drip Content: desbloqueio manual ───────────────────────────
+
+ /**
+ * Lista os desbloqueios manuais de um módulo.
+ */
+ public function moduleUnlocks(Request $request, Product $produto, MemberModule $module): JsonResponse
+ {
+ $this->authorizeProduct($produto);
+ if ($module->product_id !== $produto->id) {
+ abort(404);
+ }
+
+ $unlocks = MemberContentUnlock::where('member_module_id', $module->id)
+ ->with(['user:id,name,email', 'unlockedByUser:id,name'])
+ ->latest()
+ ->get()
+ ->map(fn (MemberContentUnlock $u) => [
+ 'id' => $u->id,
+ 'user' => $u->user ? ['id' => $u->user->id, 'name' => $u->user->name, 'email' => $u->user->email] : null,
+ 'unlocked_by' => $u->unlockedByUser ? ['id' => $u->unlockedByUser->id, 'name' => $u->unlockedByUser->name] : null,
+ 'created_at' => $u->created_at->format('d/m/Y H:i'),
+ ])
+ ->values()
+ ->all();
+
+ return response()->json(['unlocks' => $unlocks]);
+ }
+
+ /**
+ * Desbloqueia manualmente um módulo para um aluno específico.
+ */
+ public function unlockModuleForUser(Request $request, Product $produto, MemberModule $module): JsonResponse
+ {
+ $this->authorizeProduct($produto);
+ if ($module->product_id !== $produto->id) {
+ abort(404);
+ }
+
+ $validated = $request->validate([
+ 'user_id' => ['required', 'exists:users,id'],
+ ]);
+
+ // Verifica se o aluno tem acesso ao produto
+ $user = User::find($validated['user_id']);
+ if (! $user->products()->where('products.id', $produto->id)->exists()) {
+ return response()->json(['message' => 'Este aluno não tem acesso a este produto.'], 422);
+ }
+
+ MemberContentUnlock::updateOrCreate(
+ [
+ 'user_id' => $validated['user_id'],
+ 'member_module_id' => $module->id,
+ ],
+ [
+ 'unlocked_by' => $request->user()->id,
+ ]
+ );
+
+ return response()->json(['message' => 'Módulo desbloqueado para o aluno.']);
+ }
+
+ /**
+ * Remove o desbloqueio manual (re-bloqueia pelo drip original).
+ */
+ public function lockModuleForUser(Request $request, Product $produto, MemberModule $module, User $user): JsonResponse
+ {
+ $this->authorizeProduct($produto);
+ if ($module->product_id !== $produto->id) {
+ abort(404);
+ }
+
+ MemberContentUnlock::where('user_id', $user->id)
+ ->where('member_module_id', $module->id)
+ ->delete();
+
+ return response()->json(['message' => 'Desbloqueio manual removido.']);
+ }
}
diff --git a/app/Http/Controllers/MeusPedidosController.php b/app/Http/Controllers/MeusPedidosController.php
new file mode 100644
index 00000000..4a32a76a
--- /dev/null
+++ b/app/Http/Controllers/MeusPedidosController.php
@@ -0,0 +1,299 @@
+user();
+
+ $pedidos = Order::where('user_id', $user->id)
+ ->with(['product:id,name,type,image,checkout_slug', 'productOffer:id,name', 'subscriptionPlan:id,name'])
+ ->orderByDesc('created_at')
+ ->paginate(15)
+ ->withQueryString()
+ ->through(function (Order $o) {
+ $storage = app(StorageService::class);
+ return [
+ 'id' => $o->id,
+ 'status' => $o->status,
+ 'amount' => $o->amount,
+ 'gateway' => $this->gatewayLabel($o->gateway),
+ 'payment_method' => $this->paymentMethodLabel($o->payment_method),
+ 'created_at' => $o->created_at?->format('d/m/Y H:i'),
+ 'product_name' => $o->product?->name ?? '—',
+ 'product_type' => $o->product?->type ?? '—',
+ 'product_image' => $o->product?->image ? $storage->url($o->product->image) : null,
+ 'offer_name' => $o->productOffer?->name,
+ 'plan_name' => $o->subscriptionPlan?->name,
+ 'coupon_code' => $o->coupon_code,
+ ];
+ });
+
+ $hasAreaMembros = $user->products()
+ ->where('type', Product::TYPE_AREA_MEMBROS)
+ ->exists();
+
+ return Inertia::render('MemberArea/MeusPedidos', [
+ 'pedidos' => $pedidos,
+ 'hasAreaMembros' => $hasAreaMembros,
+ ]);
+ }
+
+ /**
+ * Gera recibo PDF do pedido para o aluno.
+ */
+ public function recibo(int $orderId): HttpResponse
+ {
+ $user = auth()->user();
+ $order = Order::with(['product', 'productOffer', 'subscriptionPlan'])
+ ->where('user_id', $user->id)
+ ->where('id', $orderId)
+ ->firstOrFail();
+
+ $tenantId = $order->tenant_id;
+ $appName = Setting::get('app_name', config('getfy.app_name'), $tenantId);
+ $themePrimary = Setting::get('theme_primary', config('getfy.theme_primary'), $tenantId);
+ $logoUrl = Setting::get('app_logo_dark', config('getfy.app_logo_dark'), $tenantId);
+
+ // Parse primary color to RGB
+ [$pr, $pg, $pb] = $this->hexToRgb($themePrimary);
+
+ $pdf = new \FPDF('P', 'mm', 'A4');
+ $pdf->SetAutoPageBreak(true, 20);
+ $pdf->AddPage();
+
+ // ── Logo ──
+ $logoPath = $this->downloadTempImage($logoUrl);
+ if ($logoPath) {
+ try {
+ $pdf->Image($logoPath, 15, 12, 50);
+ } catch (\Throwable $e) {
+ // Logo failed, just skip
+ }
+ @unlink($logoPath);
+ }
+
+ // ── Header bar ──
+ $pdf->SetFillColor($pr, $pg, $pb);
+ $pdf->Rect(0, 35, 210, 1.5, 'F');
+ $pdf->Ln(30);
+
+ // ── Title ──
+ $pdf->SetFont('Helvetica', 'B', 20);
+ $pdf->SetTextColor(50, 50, 50);
+ $pdf->Cell(0, 12, mb_convert_encoding('RECIBO DE COMPRA', 'ISO-8859-1', 'UTF-8'), 0, 1, 'C');
+ $pdf->Ln(4);
+
+ // ── Order info box ──
+ $pdf->SetFillColor(248, 248, 248);
+ $pdf->Rect(15, $pdf->GetY(), 180, 38, 'F');
+ $y = $pdf->GetY() + 5;
+
+ $pdf->SetFont('Helvetica', '', 10);
+ $pdf->SetTextColor(120, 120, 120);
+ $pdf->SetXY(20, $y);
+ $pdf->Cell(85, 6, mb_convert_encoding('Nº do Pedido', 'ISO-8859-1', 'UTF-8'), 0, 0);
+ $pdf->Cell(85, 6, 'Data', 0, 1);
+ $pdf->SetFont('Helvetica', 'B', 12);
+ $pdf->SetTextColor(50, 50, 50);
+ $pdf->SetX(20);
+ $pdf->Cell(85, 7, '#' . $order->id, 0, 0);
+ $pdf->Cell(85, 7, $order->created_at?->format('d/m/Y H:i') ?? '—', 0, 1);
+
+ $pdf->Ln(2);
+ $pdf->SetFont('Helvetica', '', 10);
+ $pdf->SetTextColor(120, 120, 120);
+ $pdf->SetX(20);
+ $pdf->Cell(85, 6, 'Status', 0, 0);
+ $pdf->Cell(85, 6, mb_convert_encoding('Método de Pagamento', 'ISO-8859-1', 'UTF-8'), 0, 1);
+ $pdf->SetFont('Helvetica', 'B', 12);
+ $pdf->SetTextColor(50, 50, 50);
+ $pdf->SetX(20);
+ $pdf->Cell(85, 7, mb_convert_encoding($this->statusLabel($order->status), 'ISO-8859-1', 'UTF-8'), 0, 0);
+ $methodStr = $this->gatewayLabel($order->gateway);
+ if ($order->payment_method) {
+ $methodStr .= ' — ' . mb_strtoupper($this->paymentMethodLabel($order->payment_method));
+ }
+ $pdf->Cell(85, 7, mb_convert_encoding($methodStr, 'ISO-8859-1', 'UTF-8'), 0, 1);
+
+ $pdf->Ln(10);
+
+ // ── Divider ──
+ $pdf->SetDrawColor(230, 230, 230);
+ $pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY());
+ $pdf->Ln(6);
+
+ // ── Customer info ──
+ $pdf->SetFont('Helvetica', 'B', 12);
+ $pdf->SetTextColor($pr, $pg, $pb);
+ $pdf->Cell(0, 8, mb_convert_encoding('DADOS DO COMPRADOR', 'ISO-8859-1', 'UTF-8'), 0, 1);
+ $pdf->SetFont('Helvetica', '', 10);
+ $pdf->SetTextColor(80, 80, 80);
+ $this->pdfRow($pdf, 'Nome', $user->name ?? '—');
+ $this->pdfRow($pdf, 'E-mail', $order->email ?? $user->email ?? '—');
+ if ($order->cpf) {
+ $this->pdfRow($pdf, 'CPF', $order->cpf);
+ }
+ if ($order->phone) {
+ $this->pdfRow($pdf, 'Telefone', $order->phone);
+ }
+
+ $pdf->Ln(6);
+ $pdf->SetDrawColor(230, 230, 230);
+ $pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY());
+ $pdf->Ln(6);
+
+ // ── Product details ──
+ $pdf->SetFont('Helvetica', 'B', 12);
+ $pdf->SetTextColor($pr, $pg, $pb);
+ $pdf->Cell(0, 8, mb_convert_encoding('DETALHES DO PRODUTO', 'ISO-8859-1', 'UTF-8'), 0, 1);
+ $pdf->SetFont('Helvetica', '', 10);
+ $pdf->SetTextColor(80, 80, 80);
+ $this->pdfRow($pdf, 'Produto', $order->product?->name ?? '—');
+ $this->pdfRow($pdf, 'Tipo', $this->typeLabel($order->product?->type));
+ if ($order->productOffer) {
+ $this->pdfRow($pdf, 'Oferta', $order->productOffer->name);
+ }
+ if ($order->subscriptionPlan) {
+ $this->pdfRow($pdf, 'Plano', $order->subscriptionPlan->name);
+ }
+ if ($order->coupon_code) {
+ $this->pdfRow($pdf, 'Cupom', $order->coupon_code);
+ }
+
+ $pdf->Ln(8);
+
+ // ── Total box ──
+ $pdf->SetFillColor($pr, $pg, $pb);
+ $pdf->Rect(15, $pdf->GetY(), 180, 18, 'F');
+ $pdf->SetFont('Helvetica', 'B', 14);
+ $pdf->SetTextColor(255, 255, 255);
+ $pdf->SetXY(20, $pdf->GetY() + 4);
+ $pdf->Cell(85, 10, 'TOTAL', 0, 0);
+ $pdf->Cell(80, 10, 'R$ ' . number_format((float) $order->amount, 2, ',', '.'), 0, 0, 'R');
+
+ $pdf->Ln(26);
+
+ // ── Footer ──
+ $pdf->SetFont('Helvetica', '', 8);
+ $pdf->SetTextColor(160, 160, 160);
+ $siteUrl = config('app.url', '');
+ $pdf->Cell(0, 5, mb_convert_encoding('Este recibo foi gerado automaticamente por ' . $appName . '.', 'ISO-8859-1', 'UTF-8'), 0, 1, 'C');
+ if ($siteUrl) {
+ $pdf->Cell(0, 5, mb_convert_encoding($siteUrl, 'ISO-8859-1', 'UTF-8'), 0, 1, 'C');
+ }
+ $pdf->Cell(0, 5, mb_convert_encoding('Documento não fiscal — apenas para controle pessoal.', 'ISO-8859-1', 'UTF-8'), 0, 1, 'C');
+
+ $content = $pdf->Output('S');
+
+ return response($content, 200, [
+ 'Content-Type' => 'application/pdf',
+ 'Content-Disposition' => 'inline; filename="recibo-pedido-' . $order->id . '.pdf"',
+ ]);
+ }
+
+ private function pdfRow(\FPDF $pdf, string $label, string $value): void
+ {
+ $pdf->SetFont('Helvetica', 'B', 10);
+ $pdf->Cell(40, 7, mb_convert_encoding($label . ':', 'ISO-8859-1', 'UTF-8'), 0, 0);
+ $pdf->SetFont('Helvetica', '', 10);
+ $pdf->Cell(0, 7, mb_convert_encoding($value, 'ISO-8859-1', 'UTF-8'), 0, 1);
+ }
+
+ private function hexToRgb(string $hex): array
+ {
+ $hex = ltrim($hex, '#');
+ if (strlen($hex) === 3) {
+ $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
+ }
+ return [
+ hexdec(substr($hex, 0, 2)),
+ hexdec(substr($hex, 2, 2)),
+ hexdec(substr($hex, 4, 2)),
+ ];
+ }
+
+ private function downloadTempImage(?string $url): ?string
+ {
+ if (! $url || ! filter_var($url, FILTER_VALIDATE_URL)) {
+ return null;
+ }
+ try {
+ $contents = @file_get_contents($url, false, stream_context_create([
+ 'http' => ['timeout' => 5],
+ 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
+ ]));
+ if (! $contents) {
+ return null;
+ }
+ $ext = 'png';
+ if (str_contains($url, '.jpg') || str_contains($url, '.jpeg')) {
+ $ext = 'jpg';
+ }
+ $tmp = tempnam(sys_get_temp_dir(), 'logo_') . '.' . $ext;
+ file_put_contents($tmp, $contents);
+ return $tmp;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ private function statusLabel(?string $status): string
+ {
+ return match ($status) {
+ 'completed' => 'Pago',
+ 'pending' => 'Pendente',
+ 'disputed' => 'Em disputa',
+ 'cancelled' => 'Cancelado',
+ 'refunded' => 'Reembolsado',
+ default => $status ?? '—',
+ };
+ }
+
+ private function typeLabel(?string $type): string
+ {
+ return match ($type) {
+ 'area_membros' => 'Área de Membros',
+ 'link' => 'Link',
+ 'link_pagamento' => 'Link de Pagamento',
+ 'aplicativo' => 'Aplicativo',
+ default => $type ?? '—',
+ };
+ }
+
+ private function gatewayLabel(?string $gateway): string
+ {
+ return match ($gateway) {
+ 'stripe' => 'Stripe',
+ 'mercadopago' => 'Mercado Pago',
+ 'asaas' => 'Asaas',
+ 'efi' => 'Efí',
+ 'pushinpay' => 'PushinPay',
+ 'spacepag' => 'SpacePag',
+ 'manual' => 'Manual',
+ default => $gateway ?? '—',
+ };
+ }
+
+ private function paymentMethodLabel(?string $method): string
+ {
+ return match ($method) {
+ 'pix' => 'PIX',
+ 'card' => 'Cartão',
+ 'boleto' => 'Boleto',
+ 'manual' => 'Manual',
+ default => $method ?? '—',
+ };
+ }
+}
diff --git a/app/Http/Controllers/UpsellController.php b/app/Http/Controllers/UpsellController.php
index e5465414..d0b8378b 100644
--- a/app/Http/Controllers/UpsellController.php
+++ b/app/Http/Controllers/UpsellController.php
@@ -127,7 +127,8 @@ private function getThankYouUrl(Order $order): string
if (! empty($url) && is_string($url)) {
return $url;
}
- $next = ($order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
+ $hasMemberArea = $order->product && in_array($order->product->type, [Product::TYPE_AREA_MEMBROS, Product::TYPE_APLICATIVO], true);
+ $next = ($hasMemberArea && $order->user_id && User::find($order->user_id)) ? 'member-area' : 'login';
return route('checkout.thank-you', ['next' => $next, 'order_id' => $order->id]);
}
@@ -144,17 +145,23 @@ public function thankYouPage(Request $request, AccessEmailService $accessEmailSe
$orderId = $request->integer('order_id', 0);
$conversionPixels = Product::defaultConversionPixels();
$orderAmount = 0;
+ $productType = null;
if ($orderId > 0) {
$order = Order::with('product')->find($orderId);
if ($order && $order->product) {
$conversionPixels = $order->product->conversion_pixels ?? $conversionPixels;
$orderAmount = (float) $order->amount;
+ $productType = $order->product->type;
$accessLink = $accessEmailService->getAccessLinkForOrder($order);
if ($accessLink !== '') {
$redirectUrl = $accessLink;
$redirectLabel = $order->product->type === Product::TYPE_LINK
? 'Acessar conteúdo'
: 'Acessar área de membros';
+ } elseif ($productType === Product::TYPE_LINK_PAGAMENTO) {
+ // Produto somente link de pagamento — direciona para meus pedidos
+ $redirectUrl = route('meus-pedidos.index');
+ $redirectLabel = 'Ver meus pedidos';
}
}
}
@@ -456,6 +463,7 @@ public function acceptUpsell(Request $request): RedirectResponse|array
'status' => 'pending',
'gateway' => null,
'gateway_id' => null,
+ 'payment_method' => $gateway === 'pix' ? 'pix' : null,
]);
OrderItem::create([
'order_id' => $newOrder->id,
diff --git a/app/Http/Controllers/VendasController.php b/app/Http/Controllers/VendasController.php
index 63e3a69a..d6223af3 100644
--- a/app/Http/Controllers/VendasController.php
+++ b/app/Http/Controllers/VendasController.php
@@ -40,6 +40,7 @@ public function index(Request $request): InertiaResponse
->through(function (Order $o) {
$arr = $o->toArray();
$arr['gateway_label'] = $this->gatewayLabel($o->gateway);
+ $arr['payment_method_label'] = $this->paymentMethodLabel($o->payment_method, $o->gateway);
$arr['product_display_name'] = $this->productDisplayName($o);
$arr['checkout_url'] = url('/c/' . $o->getCheckoutSlug());
$arr['payment_type_label'] = $this->paymentTypeLabel($o);
@@ -60,23 +61,40 @@ public function index(Request $request): InertiaResponse
->sum('amount');
$vendasPix = (clone $statsQuery)
- ->whereIn('gateway', ['spacepag', 'sapcepag'])
+ ->where(function ($q) {
+ $q->where('payment_method', 'pix')
+ ->orWhere(function ($q2) {
+ $q2->whereNull('payment_method')
+ ->whereIn('gateway', ['spacepag', 'sapcepag']);
+ });
+ })
->count();
$vendasCartao = (clone $statsQuery)
->where(function ($q) {
- $q->where('gateway', 'card')
- ->orWhereRaw("LOWER(gateway) LIKE '%card%'")
- ->orWhereRaw("LOWER(gateway) LIKE '%cartao%'")
- ->orWhereRaw("LOWER(gateway) LIKE '%cartão%'")
- ->orWhereRaw("LOWER(gateway) LIKE '%credito%'");
+ $q->where('payment_method', 'card')
+ ->orWhere(function ($q2) {
+ $q2->whereNull('payment_method')
+ ->where(function ($q3) {
+ $q3->where('gateway', 'card')
+ ->orWhereRaw("LOWER(gateway) LIKE '%card%'")
+ ->orWhereRaw("LOWER(gateway) LIKE '%cartao%'")
+ ->orWhereRaw("LOWER(gateway) LIKE '%credito%'");
+ });
+ });
})
->count();
$vendasBoleto = (clone $statsQuery)
->where(function ($q) {
- $q->where('gateway', 'boleto')
- ->orWhereRaw("LOWER(gateway) LIKE '%boleto%'");
+ $q->where('payment_method', 'boleto')
+ ->orWhere(function ($q2) {
+ $q2->whereNull('payment_method')
+ ->where(function ($q3) {
+ $q3->where('gateway', 'boleto')
+ ->orWhereRaw("LOWER(gateway) LIKE '%boleto%'");
+ });
+ });
})
->count();
@@ -269,14 +287,41 @@ public function approveManually(Order $order): JsonResponse
private function gatewayLabel(?string $gateway): string
{
+ return match ($gateway) {
+ 'stripe' => 'Stripe',
+ 'mercadopago' => 'Mercado Pago',
+ 'asaas' => 'Asaas',
+ 'efi' => 'Efí',
+ 'pushinpay' => 'PushinPay',
+ 'spacepag', 'sapcepag' => 'SpacePag',
+ 'manual' => 'Manual',
+ null, '' => '–',
+ default => ucfirst($gateway),
+ };
+ }
+
+ private function paymentMethodLabel(?string $paymentMethod, ?string $gateway): string
+ {
+ // Se a coluna payment_method existe, usar diretamente
+ if ($paymentMethod) {
+ return match ($paymentMethod) {
+ 'pix' => 'PIX',
+ 'card' => 'Cartão',
+ 'boleto' => 'Boleto',
+ 'manual' => 'Manual',
+ default => ucfirst($paymentMethod),
+ };
+ }
+
+ // Fallback para pedidos antigos sem payment_method: inferir do gateway
if ($gateway === null || $gateway === '') {
- return 'Outro';
+ return '–';
}
$g = strtolower($gateway);
if (in_array($g, ['spacepag', 'sapcepag'], true) || str_contains($g, 'pix')) {
return 'PIX';
}
- if ($g === 'card' || str_contains($g, 'cartao') || str_contains($g, 'cartão') || str_contains($g, 'credito')) {
+ if ($g === 'card' || str_contains($g, 'cartao') || str_contains($g, 'credito')) {
return 'Cartão';
}
if ($g === 'boleto' || str_contains($g, 'boleto')) {
@@ -286,7 +331,7 @@ private function gatewayLabel(?string $gateway): string
return 'Manual';
}
- return ucfirst($gateway);
+ return '–';
}
private function productDisplayName(Order $order): string
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index dd1c7a3e..323cc55f 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -171,6 +171,8 @@ private function pageTitleForRoute(?string $name): ?string
'api-applications.create' => 'Nova aplicação API',
'api-applications.edit' => 'Editar aplicação API',
'conquistas.index' => 'Conquistas',
+ 'meus-pedidos.index' => 'Meus Pedidos',
+ 'member-area.index' => 'Área de Membros',
];
return $name ? ($titles[$name] ?? null) : null;
diff --git a/app/Models/MemberContentUnlock.php b/app/Models/MemberContentUnlock.php
new file mode 100644
index 00000000..88ca57a3
--- /dev/null
+++ b/app/Models/MemberContentUnlock.php
@@ -0,0 +1,26 @@
+belongsTo(User::class);
+ }
+
+ public function module(): BelongsTo
+ {
+ return $this->belongsTo(MemberModule::class, 'member_module_id');
+ }
+
+ public function unlockedByUser(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'unlocked_by');
+ }
+}
diff --git a/app/Models/MemberModule.php b/app/Models/MemberModule.php
index a62c8f02..57f45ada 100644
--- a/app/Models/MemberModule.php
+++ b/app/Models/MemberModule.php
@@ -8,11 +8,11 @@
class MemberModule extends Model
{
- protected $fillable = ['member_section_id', 'product_id', 'title', 'position', 'thumbnail', 'show_title_on_cover', 'related_product_id', 'access_type', 'external_url'];
+ protected $fillable = ['member_section_id', 'product_id', 'title', 'position', 'thumbnail', 'show_title_on_cover', 'related_product_id', 'access_type', 'external_url', 'release_after_days'];
protected function casts(): array
{
- return ['position' => 'integer', 'show_title_on_cover' => 'boolean'];
+ return ['position' => 'integer', 'show_title_on_cover' => 'boolean', 'release_after_days' => 'integer'];
}
public function section(): BelongsTo
@@ -34,4 +34,49 @@ public function lessons(): HasMany
{
return $this->hasMany(MemberLesson::class, 'member_module_id')->orderBy('position');
}
+
+ public function contentUnlocks(): HasMany
+ {
+ return $this->hasMany(MemberContentUnlock::class, 'member_module_id');
+ }
+
+ /**
+ * Verifica se o módulo está bloqueado (drip) para um aluno.
+ * Retorna null se liberado, ou a data de liberação se bloqueado.
+ */
+ public function dripUnlocksAt(int $userId): ?\Carbon\Carbon
+ {
+ if ($this->release_after_days <= 0) {
+ return null;
+ }
+
+ // Se foi desbloqueado manualmente, está liberado
+ if (MemberContentUnlock::where('user_id', $userId)->where('member_module_id', $this->id)->exists()) {
+ return null;
+ }
+
+ // Busca a data do pedido mais antigo completado
+ $orderDate = \App\Models\Order::where('user_id', $userId)
+ ->where('product_id', $this->product_id)
+ ->where('status', 'completed')
+ ->orderBy('created_at')
+ ->value('created_at');
+
+ if (! $orderDate) {
+ // Sem pedido — pode ter sido adicionado manualmente; usa created_at do user_product pivot
+ $pivot = \Illuminate\Support\Facades\DB::table('user_product')
+ ->where('user_id', $userId)
+ ->where('product_id', $this->product_id)
+ ->first();
+ $orderDate = $pivot?->created_at ? \Carbon\Carbon::parse($pivot->created_at) : null;
+ }
+
+ if (! $orderDate) {
+ return \Carbon\Carbon::now()->addDays($this->release_after_days);
+ }
+
+ $unlocksAt = \Carbon\Carbon::parse($orderDate)->addDays($this->release_after_days);
+
+ return $unlocksAt->isPast() ? null : $unlocksAt;
+ }
}
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 1bcee89c..3f107ead 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -12,7 +12,7 @@ class Order extends Model
'tenant_id', 'user_id', 'product_id', 'product_offer_id', 'subscription_plan_id',
'api_application_id', 'api_checkout_session_id',
'status', 'amount', 'email', 'cpf', 'phone', 'customer_ip', 'coupon_code',
- 'gateway', 'gateway_id', 'approved_manually', 'metadata', 'period_start', 'period_end', 'is_renewal',
+ 'gateway', 'gateway_id', 'payment_method', 'approved_manually', 'metadata', 'period_start', 'period_end', 'is_renewal',
];
protected function casts(): array
diff --git a/database/migrations/2026_03_12_200000_add_payment_method_to_orders_table.php b/database/migrations/2026_03_12_200000_add_payment_method_to_orders_table.php
new file mode 100644
index 00000000..5c1fc8a7
--- /dev/null
+++ b/database/migrations/2026_03_12_200000_add_payment_method_to_orders_table.php
@@ -0,0 +1,22 @@
+string('payment_method', 20)->nullable()->after('gateway_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('orders', function (Blueprint $table) {
+ $table->dropColumn('payment_method');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_15_000001_add_drip_content_to_member_modules.php b/database/migrations/2026_03_15_000001_add_drip_content_to_member_modules.php
new file mode 100644
index 00000000..44b939a8
--- /dev/null
+++ b/database/migrations/2026_03_15_000001_add_drip_content_to_member_modules.php
@@ -0,0 +1,34 @@
+unsignedInteger('release_after_days')->default(0)->after('position');
+ });
+
+ Schema::create('member_content_unlocks', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->foreignId('member_module_id')->constrained('member_modules')->cascadeOnDelete();
+ $table->foreignId('unlocked_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->unique(['user_id', 'member_module_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('member_content_unlocks');
+
+ Schema::table('member_modules', function (Blueprint $table) {
+ $table->dropColumn('release_after_days');
+ });
+ }
+};
diff --git a/resources/js/Pages/Checkout/ThankYou.vue b/resources/js/Pages/Checkout/ThankYou.vue
index dece842e..163a103d 100644
--- a/resources/js/Pages/Checkout/ThankYou.vue
+++ b/resources/js/Pages/Checkout/ThankYou.vue
@@ -9,8 +9,8 @@ defineOptions({ layout: null });
const conversionPixelsRef = ref(null);
const props = defineProps({
- redirect_url: { type: String, default: '/' },
- redirect_label: { type: String, default: 'Acessar área de membros' },
+ redirect_url: { type: String, default: null },
+ redirect_label: { type: String, default: null },
conversion_pixels: { type: Object, default: () => ({}) },
order_id: { type: Number, default: null },
order_amount: { type: Number, default: 0 },
@@ -37,9 +37,10 @@ onMounted(() => {
Obrigado pela sua compra
- Seu pedido foi registrado. Acesse o conteúdo pelo link abaixo.
+ {{ redirect_url ? 'Seu pedido foi registrado. Acesse o conteúdo pelo link abaixo.' : 'Seu pedido foi registrado com sucesso!' }}
diff --git a/resources/js/Pages/MemberArea/Index.vue b/resources/js/Pages/MemberArea/Index.vue
index b432b7eb..427226a6 100644
--- a/resources/js/Pages/MemberArea/Index.vue
+++ b/resources/js/Pages/MemberArea/Index.vue
@@ -1,5 +1,6 @@
-
-
+
Área de Membros
-
Sair
+
+
+
+ Meus Pedidos
+
+ Sair
+
Seus produtos e cursos.
@@ -41,5 +50,4 @@ defineProps({
Você ainda não tem acesso a nenhum produto.
-
diff --git a/resources/js/Pages/MemberArea/MeusPedidos.vue b/resources/js/Pages/MemberArea/MeusPedidos.vue
new file mode 100644
index 00000000..f0bec417
--- /dev/null
+++ b/resources/js/Pages/MemberArea/MeusPedidos.vue
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
Meus Pedidos
+
Histórico de todas as suas compras.
+
+
+
+
+ Área de Membros
+
+
Sair
+
+
+
+
+
+
+
+
+ Produto
+ Tipo
+ Valor
+ Pagamento
+ Status
+ Data
+ Recibo
+
+
+
+
+
+
+
+
+
+
+
+
{{ p.product_name }}
+
{{ p.offer_name }}
+
{{ p.plan_name }}
+
+
+
+ {{ typeLabel(p.product_type) }}
+ {{ formatBRL(p.amount) }}
+
+ {{ p.gateway }}
+ {{ p.payment_method }}
+
+
+
+ {{ statusLabel(p.status) }}
+
+
+ {{ p.created_at }}
+
+
+
+ PDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ p.product_name }}
+
{{ p.offer_name }}
+
{{ p.plan_name }}
+
+
+
+ {{ statusLabel(p.status) }}
+
+
+
+ {{ typeLabel(p.product_type) }}
+ {{ formatBRL(p.amount) }}
+
+
+ {{ p.gateway }} — {{ p.payment_method }}
+ {{ p.created_at }}
+
+
+ Cupom: {{ p.coupon_code }}
+
+
+
+ Ver Recibo
+
+
+
+
+
+
+
+
+
+
Nenhum pedido encontrado.
+
Suas compras aparecerão aqui.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/MemberAreaApp/ModuleContent.vue b/resources/js/Pages/MemberAreaApp/ModuleContent.vue
index 13c8e320..122739ac 100644
--- a/resources/js/Pages/MemberAreaApp/ModuleContent.vue
+++ b/resources/js/Pages/MemberAreaApp/ModuleContent.vue
@@ -5,7 +5,7 @@ import MemberAreaAppLayout from '@/Layouts/MemberAreaAppLayout.vue';
import Button from '@/components/ui/Button.vue';
import MemberAreaVideoPlayer from '@/components/MemberAreaVideoPlayer.vue';
import { formatLessonDescription } from '@/lib/utils';
-import { Link as LinkIcon, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next';
+import { Link as LinkIcon, CheckCircle, ChevronLeft, ChevronRight, Lock } from 'lucide-vue-next';
defineOptions({ layout: MemberAreaAppLayout });
@@ -116,6 +116,17 @@ function scrollCarousel(sectionId, direction) {
if (!el) return;
el.scrollBy({ left: 272 * direction, behavior: 'smooth' });
}
+
+function formatUnlockDate(isoDate) {
+ if (!isoDate) return '';
+ const d = new Date(isoDate);
+ const now = new Date();
+ const diffMs = d - now;
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
+ if (diffDays <= 0) return 'Em breve';
+ if (diffDays === 1) return 'Amanhã';
+ return `em ${diffDays} dias`;
+}
@@ -267,21 +278,39 @@ function scrollCarousel(sectionId, direction) {
:ref="(el) => setCarouselRef(section.id, el)"
class="no-scrollbar flex gap-4 overflow-x-auto"
>
-
-
-
-
-
-
{{ mod.title }}
+
+
+
+
+
+
+
+ Disponível {{ formatUnlockDate(mod.unlocks_at) }}
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/MemberAreaApp/Modulos.vue b/resources/js/Pages/MemberAreaApp/Modulos.vue
index ab59d328..ea51b6e9 100644
--- a/resources/js/Pages/MemberAreaApp/Modulos.vue
+++ b/resources/js/Pages/MemberAreaApp/Modulos.vue
@@ -1,5 +1,6 @@
@@ -20,7 +32,20 @@ const props = defineProps({
{{ section.title }}
-
+
+
+
+
+
+ Disponível {{ formatUnlockDate(mod.unlocks_at) }}
+
+
+
+
+
+
diff --git a/resources/js/Pages/MemberAreaApp/Show.vue b/resources/js/Pages/MemberAreaApp/Show.vue
index cdc7ff48..420b3e2f 100644
--- a/resources/js/Pages/MemberAreaApp/Show.vue
+++ b/resources/js/Pages/MemberAreaApp/Show.vue
@@ -1,7 +1,7 @@
@@ -176,20 +187,38 @@ const heroGradient = 'linear-gradient(135deg, var(--ma-primary) 0%, #27272a 100%
>
-
-
-
-
-
-
{{ mod.title }}
+
+
+
+
+
+
+
+ Disponível {{ formatUnlockDate(mod.unlocks_at) }}
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/Produtos/MemberBuilder/Index.vue b/resources/js/Pages/Produtos/MemberBuilder/Index.vue
index f7708999..4c999bed 100644
--- a/resources/js/Pages/Produtos/MemberBuilder/Index.vue
+++ b/resources/js/Pages/Produtos/MemberBuilder/Index.vue
@@ -29,6 +29,9 @@ import {
ExternalLink,
X,
Trophy,
+ Lock,
+ Unlock,
+ Clock,
} from 'lucide-vue-next';
const props = defineProps({
@@ -217,6 +220,60 @@ function deleteCommunityPage(pageId) {
}
const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200';
+
+// ─── Drip Content ──────────────────────────────────────────
+function updateModuleDrip(moduleId, days) {
+ const value = Math.max(0, Math.min(365, parseInt(days, 10) || 0));
+ axios.put(`${base.value}/modules/${moduleId}`, { release_after_days: value }, {
+ headers: { 'X-CSRF-TOKEN': csrfToken(), Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
+ }).catch(() => {});
+}
+
+const unlockModal = reactive({ open: false, moduleId: null, moduleTitle: '', unlocks: [], loading: false });
+const unlockForm = reactive({ user_id: '' });
+
+async function openUnlockModal(mod) {
+ unlockModal.moduleId = mod.id;
+ unlockModal.moduleTitle = mod.title;
+ unlockModal.open = true;
+ unlockModal.loading = true;
+ unlockForm.user_id = '';
+ try {
+ const { data } = await axios.get(`${base.value}/modules/${mod.id}/unlocks`, {
+ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
+ });
+ unlockModal.unlocks = data.unlocks ?? [];
+ } catch {
+ unlockModal.unlocks = [];
+ } finally {
+ unlockModal.loading = false;
+ }
+}
+
+async function doUnlock() {
+ if (!unlockForm.user_id) return;
+ try {
+ await axios.post(`${base.value}/modules/${unlockModal.moduleId}/unlock`, { user_id: unlockForm.user_id }, {
+ headers: { 'X-CSRF-TOKEN': csrfToken(), Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
+ });
+ unlockForm.user_id = '';
+ await openUnlockModal({ id: unlockModal.moduleId, title: unlockModal.moduleTitle });
+ } catch (e) {
+ alert(e.response?.data?.message ?? 'Erro ao desbloquear.');
+ }
+}
+
+async function doLock(userId) {
+ if (!confirm('Remover desbloqueio manual?')) return;
+ try {
+ await axios.delete(`${base.value}/modules/${unlockModal.moduleId}/unlock/${userId}`, {
+ headers: { 'X-CSRF-TOKEN': csrfToken(), Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
+ });
+ await openUnlockModal({ id: unlockModal.moduleId, title: unlockModal.moduleTitle });
+ } catch {
+ alert('Erro ao remover desbloqueio.');
+ }
+}
@@ -394,6 +451,29 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3
+
+
+
+ Liberar após
+ updateModuleDrip(mod.id, e.target.value)"
+ />
+ dias
+
+ Desbloqueios
+
+
— {{ lesson.title }}
@@ -639,5 +719,51 @@ const inputClass = 'block w-full rounded-lg border border-zinc-300 bg-white px-3
+
+
+
+
+
+
+
+
+ Desbloqueios manuais — {{ unlockModal.moduleTitle }}
+
+
+
+
+
+ Desbloqueia este módulo imediatamente para um aluno, ignorando o prazo de liberação.
+
+
+
+
+
+ Selecione um aluno...
+ {{ u.name }} ({{ u.email }})
+
+
+ Desbloquear
+
+
+
+
+
Carregando...
+
+
+
+ {{ u.user?.name ?? 'Aluno removido' }}
+ {{ u.user?.email }}
+ {{ u.created_at }}
+ (por {{ u.unlocked_by.name }})
+
+
Remover
+
+
+
Nenhum desbloqueio manual.
+
+
+
+
diff --git a/resources/js/Pages/Vendas/Index.vue b/resources/js/Pages/Vendas/Index.vue
index f09826e9..b1b2d0fd 100644
--- a/resources/js/Pages/Vendas/Index.vue
+++ b/resources/js/Pages/Vendas/Index.vue
@@ -343,7 +343,7 @@ onUnmounted(() => {
{{ statusBadgeLabel(v.status) }}
- {{ v.gateway_label ?? '–' }}
+ {{ v.gateway_label ?? '–' }} · {{ v.payment_method_label }}
diff --git a/resources/js/components/vendas/VendaDetailSidebar.vue b/resources/js/components/vendas/VendaDetailSidebar.vue
index 027d57cd..9626fc3d 100644
--- a/resources/js/components/vendas/VendaDetailSidebar.vue
+++ b/resources/js/components/vendas/VendaDetailSidebar.vue
@@ -132,9 +132,13 @@ function statusLabel(status) {
{{ venda.product_display_name ?? venda.product?.name ?? '–' }}
-
Método de pagamento
+
Gateway
{{ venda.gateway_label ?? '–' }}
+
+
Método de pagamento
+
{{ venda.payment_method_label ?? '–' }}
+
Parcelas
1
diff --git a/routes/web.php b/routes/web.php
index df119062..248e07d7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -311,10 +311,17 @@
Route::put('/produtos/{produto}/member-builder/community-pages/{page}', [\App\Http\Controllers\MemberBuilderController::class, 'updateCommunityPage'])->name('member-builder.community-pages.update');
Route::delete('/produtos/{produto}/member-builder/community-pages/{page}', [\App\Http\Controllers\MemberBuilderController::class, 'destroyCommunityPage'])->name('member-builder.community-pages.destroy');
Route::post('/produtos/{produto}/member-builder/send-push', [\App\Http\Controllers\MemberBuilderController::class, 'sendPushNotification'])->name('member-builder.send-push');
+
+ // Drip content: desbloqueio manual
+ Route::get('/produtos/{produto}/member-builder/modules/{module}/unlocks', [\App\Http\Controllers\MemberBuilderController::class, 'moduleUnlocks'])->name('member-builder.modules.unlocks');
+ Route::post('/produtos/{produto}/member-builder/modules/{module}/unlock', [\App\Http\Controllers\MemberBuilderController::class, 'unlockModuleForUser'])->name('member-builder.modules.unlock');
+ Route::delete('/produtos/{produto}/member-builder/modules/{module}/unlock/{user}', [\App\Http\Controllers\MemberBuilderController::class, 'lockModuleForUser'])->name('member-builder.modules.lock');
});
Route::middleware(['auth', 'role:aluno'])->group(function () {
Route::get('/area-membros', [\App\Http\Controllers\MemberAreaController::class, 'index'])->name('member-area.index');
+ Route::get('/meus-pedidos', [\App\Http\Controllers\MeusPedidosController::class, 'index'])->name('meus-pedidos.index');
+ Route::get('/meus-pedidos/{order}/recibo', [\App\Http\Controllers\MeusPedidosController::class, 'recibo'])->name('meus-pedidos.recibo');
});
// Área de membros por produto (path: /m/{slug})