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 @@ 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 @@ + + + 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`; +}