feat(cms): implement complete SysSiteCMS content management system

Initial implementation of the HomeSysPanel CMS built with Laravel 13.0.
Includes complete admin panel, content management, user authentication,
modular architecture for products/services/chatbot, and marketing tools.

Key features:
- User authentication with email verification and password reset
- Role-based access control (Admin/User with granular permissions)
- Content management (pages, news, FAQ, downloads, galleries, banners)
- Contact management system with subjects and responses
- Marketing tools (email campaigns, visitor analytics, social sharing)
- Modular architecture (Products, Services, Chatbot modules)
- AI Chatbot integration with OpenRouter.ai and LlamaCpp support
- Visitor tracking with geolocation
- SEO optimization with meta tags and Open Graph
- Responsive design with Bootstrap 5
- Dark mode support
- Maintenance mode
- Feature request system
This commit is contained in:
marco
2026-09-07 19:06:53 -03:00
commit 38295e7e88
279 changed files with 33691 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Banner;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BannerController extends Controller
{
public function index()
{
$banners = Banner::orderBy('order')->paginate(10);
return view('admin.banners.index', compact('banners'));
}
public function create()
{
return view('admin.banners.create');
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'image' => 'required|image|max:2048|mimes:jpeg,png,jpg,gif,webp',
'link' => 'nullable|string|max:500|regex:/^https?:\/\/.+\..+$/i',
'button_text' => 'nullable|string|max:50',
'order' => 'required|integer',
'active' => 'required|boolean',
], [
'image.mimes' => 'A imagem deve ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
'link.regex' => 'O link deve começar com http:// ou https:// e ter um formato válido.',
]);
$data = $request->all();
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('banners', 'public');
}
Banner::create($data);
return redirect()->route('admin.marketing.banners.index')->with('success', 'Banner criado com sucesso.');
}
public function edit(Banner $banner)
{
return view('admin.banners.edit', compact('banner'));
}
public function update(Request $request, Banner $banner)
{
$request->validate([
'title' => 'required|string|max:255',
'image' => 'nullable|image|max:2048|mimes:jpeg,png,jpg,gif,webp',
'link' => 'nullable|string|max:500|regex:/^https?:\/\/.+\..+$/i',
'button_text' => 'nullable|string|max:50',
'order' => 'required|integer',
'active' => 'required|boolean',
], [
'image.mimes' => 'A imagem deve ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
'link.regex' => 'O link deve começar com http:// ou https:// e ter um formato válido.',
]);
$data = $request->all();
if ($request->hasFile('image')) {
if ($banner->image) {
Storage::disk('public')->delete($banner->image);
}
$data['image'] = $request->file('image')->store('banners', 'public');
}
$banner->update($data);
return redirect()->route('admin.marketing.banners.index')->with('success', 'Banner atualizado com sucesso.');
}
public function destroy(Banner $banner)
{
if ($banner->image) {
Storage::disk('public')->delete($banner->image);
}
$banner->delete();
return redirect()->route('admin.marketing.banners.index')->with('success', 'Banner excluído com sucesso.');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ContactMessage;
use App\Models\ContactResponse;
use App\Mail\ContactReplyMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Auth;
class ContactController extends Controller
{
public function index()
{
$contacts = ContactMessage::with('subject')->latest()->paginate(10);
return view('admin.contacts.index', compact('contacts'));
}
public function show(ContactMessage $contact)
{
$contact->update(['read' => true]);
$contact->load('responses.user', 'subject');
return view('admin.contacts.show', compact('contact'));
}
public function reply(Request $request, ContactMessage $contact)
{
$request->validate([
'message' => 'required|string|min:10',
]);
// 1. Salvar no Banco de Dados
ContactResponse::create([
'contact_message_id' => $contact->id,
'user_id' => Auth::id(),
'message' => $request->message,
]);
// 2. Enviar E-mail
Mail::to($contact->email)->send(new ContactReplyMail($contact, $request->message));
return redirect()->route('admin.contacts.show', $contact)->with('success', 'Resposta enviada e registrada com sucesso!');
}
public function destroy(ContactMessage $contact)
{
$contact->delete();
return redirect()->route('admin.contacts.index')->with('success', 'Mensagem excluída com sucesso.');
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Download;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DownloadController extends Controller
{
public function index()
{
$downloads = Download::latest()->paginate(10);
return view('admin.downloads.index', compact('downloads'));
}
public function create()
{
return view('admin.downloads.create');
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'file' => 'required|file|max:20480|mimes:pdf,doc,docx,zip,rar,txt,xls,xlsx', // Máx 20MB
'file_type' => 'required|string|in:Driver,Manual,Software,Outro',
], [
'file.mimes' => 'O arquivo deve ser do tipo: PDF, DOC, DOCX, ZIP, RAR, TXT, XLS ou XLSX.',
]);
$path = $request->file('file')->store('downloads', 'public');
Download::create([
'title' => $request->title,
'description' => $request->description,
'file_path' => $path,
'file_type' => $request->file_type,
'active' => true,
]);
return redirect()->route('admin.downloads.index')->with('success', 'Download cadastrado com sucesso.');
}
public function edit(Download $download)
{
return view('admin.downloads.edit', compact('download'));
}
public function update(Request $request, Download $download)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'file' => 'nullable|file|max:20480|mimes:pdf,doc,docx,zip,rar,txt,xls,xlsx',
'file_type' => 'required|string|in:Driver,Manual,Software,Outro',
'active' => 'required|boolean',
], [
'file.mimes' => 'O arquivo deve ser do tipo: PDF, DOC, DOCX, ZIP, RAR, TXT, XLS ou XLSX.',
]);
$data = [
'title' => $request->title,
'description' => $request->description,
'file_type' => $request->file_type,
'active' => $request->active,
];
if ($request->hasFile('file')) {
// Remove o arquivo antigo
Storage::disk('public')->delete($download->file_path);
$data['file_path'] = $request->file('file')->store('downloads', 'public');
}
$download->update($data);
return redirect()->route('admin.downloads.index')->with('success', 'Download atualizado com sucesso.');
}
public function destroy(Download $download)
{
Storage::disk('public')->delete($download->file_path);
$download->delete();
return redirect()->route('admin.downloads.index')->with('success', 'Download excluído com sucesso.');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Faq;
use Illuminate\Http\Request;
class FaqController extends Controller
{
public function index()
{
$faqs = Faq::orderBy('order')->latest()->paginate(10);
return view('admin.faqs.index', compact('faqs'));
}
public function create()
{
return view('admin.faqs.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'question' => 'required|string|max:255',
'answer' => 'required|string',
'order' => 'required|integer',
]);
$validated['active'] = true;
Faq::create($validated);
return redirect()->route('admin.faqs.index')->with('success', 'FAQ criado com sucesso.');
}
public function edit(Faq $faq)
{
return view('admin.faqs.edit', compact('faq'));
}
public function update(Request $request, Faq $faq)
{
$validated = $request->validate([
'question' => 'required|string|max:255',
'answer' => 'required|string',
'order' => 'required|integer',
'active' => 'required|boolean',
]);
$faq->update($validated);
return redirect()->route('admin.faqs.index')->with('success', 'FAQ atualizado com sucesso.');
}
public function destroy(Faq $faq)
{
$faq->delete();
return redirect()->route('admin.faqs.index')->with('success', 'FAQ excluído com sucesso.');
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GalleryCategory;
use Illuminate\Http\Request;
class GalleryCategoryController extends Controller
{
public function index()
{
$categories = GalleryCategory::withCount('galleries')->latest()->paginate(10);
return view('admin.gallery-categories.index', compact('categories'));
}
public function create()
{
return view('admin.gallery-categories.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:gallery_categories',
'description' => 'nullable|string',
]);
GalleryCategory::create([
'name' => $request->name,
'slug' => $request->slug,
'description' => $request->description,
'active' => true,
]);
return redirect()->route('admin.gallery-categories.index')->with('success', 'Categoria criada com sucesso.');
}
public function edit(GalleryCategory $galleryCategory)
{
return view('admin.gallery-categories.edit', compact('galleryCategory'));
}
public function update(Request $request, GalleryCategory $galleryCategory)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:gallery_categories,slug,' . $galleryCategory->id,
'description' => 'nullable|string',
'active' => 'required|boolean',
]);
$galleryCategory->update($request->all());
return redirect()->route('admin.gallery-categories.index')->with('success', 'Categoria atualizada com sucesso.');
}
public function destroy(GalleryCategory $galleryCategory)
{
$galleryCategory->delete();
return redirect()->route('admin.gallery-categories.index')->with('success', 'Categoria excluída com sucesso.');
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Gallery;
use App\Models\GalleryCategory;
use App\Models\GalleryImage;
use App\Models\User;
use App\Notifications\GalleryActionNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
class GalleryController extends Controller
{
public function index()
{
$galleries = Gallery::withCount('images')->latest()->paginate(10);
return view('admin.galleries.index', compact('galleries'));
}
public function create()
{
$categories = GalleryCategory::where('active', true)->orderBy('name')->get();
return view('admin.galleries.create', compact('categories'));
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:galleries',
'description' => 'nullable|string',
'category_id' => 'nullable|exists:gallery_categories,id',
]);
$gallery = Gallery::create([
'title' => $request->title,
'slug' => $request->slug,
'description' => $request->description,
'category_id' => $request->category_id,
'active' => true,
]);
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new GalleryActionNotification($gallery, $user, 'created'));
return redirect()->route('admin.galleries.index')->with('success', 'Galeria criada com sucesso.');
}
public function edit(Gallery $gallery)
{
$gallery->load('images');
$categories = GalleryCategory::where('active', true)->orderBy('name')->get();
return view('admin.galleries.edit', compact('gallery', 'categories'));
}
public function update(Request $request, Gallery $gallery)
{
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:galleries,slug,' . $gallery->id,
'description' => 'nullable|string',
'active' => 'required|boolean',
'category_id' => 'nullable|exists:gallery_categories,id',
]);
$gallery->update($request->all());
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new GalleryActionNotification($gallery, $user, 'updated'));
return redirect()->route('admin.galleries.index')->with('success', 'Galeria atualizada com sucesso.');
}
public function destroy(Gallery $gallery)
{
foreach ($gallery->images as $image) {
Storage::disk('public')->delete($image->file_path);
}
$gallery->delete();
return redirect()->route('admin.galleries.index')->with('success', 'Galeria e suas imagens excluídas.');
}
// Gerenciamento de Imagens
public function uploadImage(Request $request, Gallery $gallery)
{
$request->validate([
'images' => 'required|array|min:1|max:20',
'images.*' => 'image|max:5120|mimes:jpeg,png,jpg,gif,webp', // 5MB each
], [
'images.max' => 'Máximo de 20 imagens permitidas.',
'images.*.mimes' => 'As imagens devem ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
]);
$order = $gallery->images()->count() + 1;
$count = 0;
foreach ($request->file('images') as $file) {
$path = $file->store('galleries/' . $gallery->id, 'public');
GalleryImage::create([
'gallery_id' => $gallery->id,
'file_path' => $path,
'order' => $order++,
]);
$count++;
}
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new GalleryActionNotification($gallery, $user, 'photo_added'));
$message = $count === 1 ? 'Imagem adicionada à galeria.' : "{$count} imagens adicionadas à galeria.";
return redirect()->back()->with('success', $message);
}
public function updateImage(Request $request, GalleryImage $image)
{
$request->validate([
'description' => 'nullable|string|max:255',
'order' => 'required|integer',
]);
$image->update($request->only('description', 'order'));
return redirect()->back()->with('success', 'Imagem atualizada.');
}
public function deleteImage(GalleryImage $image)
{
$gallery = $image->gallery;
Storage::disk('public')->delete($image->file_path);
$image->delete();
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new GalleryActionNotification($gallery, $user, 'photo_deleted'));
return redirect()->back()->with('success', 'Imagem removida.');
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Setting;
use App\Models\VisitorLog;
use App\Mail\PromotionMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class MarketingController extends Controller
{
public function index()
{
return view('admin.marketing.index');
}
public function promoteProducts()
{
if (!module_enabled('products') || !class_exists(\App\Modules\Products\Models\Product::class)) {
return redirect()->route('admin.marketing.index')->with('error', 'O módulo de Produtos está desativado.');
}
$products = \App\Modules\Products\Models\Product::where('active', true)->with('category')->get();
return view('admin.marketing.promote_products', compact('products'));
}
public function promoteServices()
{
if (!module_enabled('services') || !class_exists(\App\Modules\Services\Models\Service::class)) {
return redirect()->route('admin.marketing.index')->with('error', 'O módulo de Serviços está desativado.');
}
$services = \App\Modules\Services\Models\Service::where('active', true)->get();
return view('admin.marketing.promote_services', compact('services'));
}
public function analytics()
{
// Grouping by Country
$countries = VisitorLog::select('country', DB::raw('count(*) as total'))
->groupBy('country')
->orderBy('total', 'desc')
->limit(10)
->get();
// Grouping by City
$cities = VisitorLog::select('city', 'region', DB::raw('count(*) as total'))
->groupBy('city', 'region')
->orderBy('total', 'desc')
->limit(10)
->get();
// Data for last 7 days access chart
$last7Days = collect(range(6, 0))->map(function ($days) {
$date = now()->subDays($days)->format('Y-m-d');
return [
'date' => now()->subDays($days)->format('d/m'),
'total' => VisitorLog::whereDate('created_at', $date)->count()
];
});
return view('admin.marketing.analytics', compact('countries', 'cities', 'last7Days'));
}
public function emailMarketing()
{
$products = collect();
$services = collect();
if (module_enabled('products') && class_exists(\App\Modules\Products\Models\Product::class)) {
$products = \App\Modules\Products\Models\Product::where('active', true)->get();
}
if (module_enabled('services') && class_exists(\App\Modules\Services\Models\Service::class)) {
$services = \App\Modules\Services\Models\Service::where('active', true)->get();
}
$userCount = User::where('user_type', 'User')->count();
return view('admin.marketing.email', compact('products', 'services', 'userCount'));
}
public function sendEmailMarketing(Request $request)
{
$request->validate([
'subject' => 'required|string|max:255',
'message' => 'required|string',
'button_text' => 'nullable|string|max:50',
'button_url' => 'nullable|string|max:500|regex:/^https?:\/\/.+\..+$/i',
], [
'button_url.regex' => 'O link do botão deve começar com http:// ou https:// e ter um formato válido.',
]);
$users = User::where('user_type', 'User')->get();
$company = (object)[
'name' => Setting::get('company_name', config('app.name')),
];
$productImage = null;
if ($request->product_id && module_enabled('products') && class_exists(\App\Modules\Products\Models\Product::class)) {
$product = \App\Modules\Products\Models\Product::find($request->product_id);
if ($product && $product->image) {
$productImage = asset('storage/' . $product->image);
}
} elseif ($request->service_id && module_enabled('services') && class_exists(\App\Modules\Services\Models\Service::class)) {
$service = \App\Modules\Services\Models\Service::find($request->service_id);
if ($service && $service->image) {
$productImage = asset('storage/' . $service->image);
}
}
foreach ($users as $user) {
// Check if user wants to receive emails (preference)
$prefs = $user->preferences ?? [];
if ($prefs['notifications_email'] ?? true) {
try {
Mail::to($user->email)->send(new PromotionMail(
$request->subject,
$request->message,
$request->button_text,
$request->button_url,
$productImage,
$company
));
} catch (\Exception $e) {
Log::error("Erro ao enviar e-mail de marketing para {$user->email}: " . $e->getMessage());
}
}
}
return redirect()->route('admin.marketing.email')->with('success', 'E-mail de marketing enviado para ' . $users->count() . ' clientes!');
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\News;
use App\Models\NewsImage;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\NewsActionNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
class NewsController extends Controller
{
public function index()
{
$newsList = News::withCount('images')->latest()->paginate(10);
$newsEnabled = Setting::get('news_enabled', 'on') === 'on';
return view('admin.news.index', compact('newsList', 'newsEnabled'));
}
public function toggleVisibility()
{
$current = Setting::get('news_enabled', 'on');
$newValue = $current === 'on' ? 'off' : 'on';
Setting::set('news_enabled', $newValue, 'news');
$status = $newValue === 'on' ? 'ativado' : 'desativado';
return redirect()->route('admin.news.index')->with('success', "Módulo de Notícias {$status} com sucesso.");
}
public function create()
{
return view('admin.news.create');
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:news',
'summary' => 'nullable|string|max:500',
'content' => 'nullable|string',
'author' => 'nullable|string|max:255',
'featured_image' => 'nullable|image|max:5120|mimes:jpeg,png,jpg,gif,webp',
'published_at' => 'nullable|date',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:255',
], [
'featured_image.mimes' => 'A imagem deve ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
]);
$data = $request->except('featured_image');
$data['active'] = true;
$data['user_id'] = Auth::id();
if ($request->hasFile('featured_image')) {
$data['featured_image'] = $request->file('featured_image')->store('news/featured', 'public');
}
$news = News::create($data);
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new NewsActionNotification($news, $user, 'created'));
return redirect()->route('admin.news.index')->with('success', 'Notícia criada com sucesso.');
}
public function edit(News $news)
{
$news->load('images');
return view('admin.news.edit', compact('news'));
}
public function update(Request $request, News $news)
{
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:news,slug,' . $news->id,
'summary' => 'nullable|string|max:500',
'content' => 'nullable|string',
'author' => 'nullable|string|max:255',
'featured_image' => 'nullable|image|max:5120',
'active' => 'required|boolean',
'published_at' => 'nullable|date',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:255',
]);
$data = $request->except('featured_image');
if ($request->hasFile('featured_image')) {
// Delete old featured image
if ($news->featured_image) {
Storage::disk('public')->delete($news->featured_image);
}
$data['featured_image'] = $request->file('featured_image')->store('news/featured', 'public');
}
$news->update($data);
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new NewsActionNotification($news, $user, 'updated'));
return redirect()->route('admin.news.index')->with('success', 'Notícia atualizada com sucesso.');
}
public function destroy(News $news)
{
// Delete featured image
if ($news->featured_image) {
Storage::disk('public')->delete($news->featured_image);
}
// Delete all additional images
foreach ($news->images as $image) {
Storage::disk('public')->delete($image->file_path);
}
$news->delete();
return redirect()->route('admin.news.index')->with('success', 'Notícia excluída com sucesso.');
}
// Featured Image Management
public function removeFeaturedImage(News $news)
{
if ($news->featured_image) {
Storage::disk('public')->delete($news->featured_image);
$news->update(['featured_image' => null]);
}
return redirect()->back()->with('success', 'Imagem principal removida.');
}
// Additional Images Management
public function uploadImage(Request $request, News $news)
{
$request->validate([
'images' => 'required|array|min:1|max:10',
'images.*' => 'image|max:5120|mimes:jpeg,png,jpg,gif,webp', // 5MB each
], [
'images.max' => 'Máximo de 10 imagens permitidas.',
'images.*.mimes' => 'As imagens devem ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
]);
$order = $news->images()->count() + 1;
$count = 0;
foreach ($request->file('images') as $file) {
$path = $file->store('news/' . $news->id, 'public');
NewsImage::create([
'news_id' => $news->id,
'file_path' => $path,
'order' => $order++,
]);
$count++;
}
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new NewsActionNotification($news, $user, 'photo_added'));
$message = $count === 1 ? 'Imagem adicionada à notícia.' : "{$count} imagens adicionadas à notícia.";
return redirect()->back()->with('success', $message);
}
public function updateImage(Request $request, NewsImage $image)
{
$request->validate([
'description' => 'nullable|string|max:255',
'order' => 'required|integer',
]);
$image->update($request->only('description', 'order'));
return redirect()->back()->with('success', 'Imagem atualizada.');
}
public function deleteImage(NewsImage $image)
{
$news = $image->news;
Storage::disk('public')->delete($image->file_path);
$image->delete();
/** @var \App\Models\User $user */
$user = Auth::user();
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new NewsActionNotification($news, $user, 'photo_deleted'));
return redirect()->back()->with('success', 'Imagem removida.');
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Page;
use App\Models\User;
use App\Notifications\PageActionNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
class PageController extends Controller
{
public function index()
{
$pages = Page::latest()->paginate(10);
return view('admin.pages.index', compact('pages'));
}
public function create()
{
return view('admin.pages.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:pages',
'content' => 'nullable|string',
'meta_description' => 'nullable|string|max:255',
'meta_keywords' => 'nullable|string|max:255',
'og_title' => 'nullable|string|max:255',
'og_description' => 'nullable|string|max:255',
'og_image' => 'nullable|string|max:255',
'canonical_url' => 'nullable|string|max:255',
'active' => 'required|boolean',
]);
$page = Page::create($validated);
/** @var \App\Models\User $user */
$user = Auth::user();
// Notificar outros administradores sobre a nova página
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new PageActionNotification($page, $user, 'created'));
return redirect()->route('admin.pages.index')->with('success', 'Página criada com sucesso.');
}
public function edit(Page $page)
{
return view('admin.pages.edit', compact('page'));
}
public function update(Request $request, Page $page)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:pages,slug,' . $page->id,
'content' => 'nullable|string',
'meta_description' => 'nullable|string|max:255',
'meta_keywords' => 'nullable|string|max:255',
'og_title' => 'nullable|string|max:255',
'og_description' => 'nullable|string|max:255',
'og_image' => 'nullable|string|max:255',
'canonical_url' => 'nullable|string|max:255',
'active' => 'required|boolean',
]);
$page->update($validated);
/** @var \App\Models\User $user */
$user = Auth::user();
// Notificar outros administradores sobre a atualização
$admins = User::where('user_type', 'Admin')->where('id', '!=', $user->id)->get();
Notification::send($admins, new PageActionNotification($page, $user, 'updated'));
return redirect()->route('admin.pages.index')->with('success', 'Página atualizada com sucesso.');
}
public function destroy(Page $page)
{
$page->delete();
return redirect()->route('admin.pages.index')->with('success', 'Página excluída com sucesso.');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class SettingController extends Controller
{
public function index()
{
$settings = Setting::all()->groupBy('group');
return view('admin.settings.index', compact('settings'));
}
public function update(Request $request)
{
$settings = $request->except('_token');
foreach ($settings as $key => $value) {
$group = 'general';
if (str_starts_with($key, 'seo_')) $group = 'seo';
if (str_starts_with($key, 'scripts_')) $group = 'scripts';
if (str_starts_with($key, 'company_')) $group = 'company';
if (str_starts_with($key, 'dashboard_')) $group = 'dashboard';
if (str_starts_with($key, 'marketing_')) $group = 'marketing';
Setting::set($key, $value, $group);
}
// Limpa o cache para garantir que as novas configurações sejam aplicadas
Artisan::call('cache:clear');
Artisan::call('view:clear');
return redirect()->back()->with('success', 'Configurações globais atualizadas com sucesso.');
}
public function erpAd()
{
return view('admin.erp.ad');
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ContactSubject;
use Illuminate\Http\Request;
class SubjectController extends Controller
{
public function index()
{
$subjects = ContactSubject::latest()->paginate(10);
return view('admin.subjects.index', compact('subjects'));
}
public function create()
{
return view('admin.subjects.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:contact_subjects',
]);
ContactSubject::create($request->all());
return redirect()->route('admin.subjects.index')->with('success', 'Assunto criado com sucesso.');
}
public function edit(ContactSubject $subject)
{
return view('admin.subjects.edit', compact('subject'));
}
public function update(Request $request, ContactSubject $subject)
{
$request->validate([
'name' => 'required|string|max:255|unique:contact_subjects,name,' . $subject->id,
'active' => 'required|boolean',
]);
$subject->update($request->all());
return redirect()->route('admin.subjects.index')->with('success', 'Assunto atualizado com sucesso.');
}
public function destroy(ContactSubject $subject)
{
if ($subject->messages()->count() > 0) {
return redirect()->route('admin.subjects.index')->with('error', 'Não é possível excluir um assunto que possui mensagens vinculadas.');
}
$subject->delete();
return redirect()->route('admin.subjects.index')->with('success', 'Assunto excluído com sucesso.');
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\UserType;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index()
{
$users = User::latest()->paginate(10);
$availablePermissions = User::getAvailablePermissions();
$userTypes = UserType::getAllForSelect();
return view('admin.users.index', compact('users', 'availablePermissions', 'userTypes'));
}
public function create()
{
$availablePermissions = User::getAvailablePermissions();
$userTypes = UserType::getAllForSelect();
return view('admin.users.create', compact('availablePermissions', 'userTypes'));
}
public function store(Request $request)
{
$validTypes = UserType::pluck('name')->toArray();
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
'user_type' => 'required|string|in:' . implode(',', $validTypes),
'company' => 'nullable|string|max:255',
'website' => 'nullable|string|max:255|regex:/^https?:\/\/.+\..+$/i',
'whatsapp' => 'nullable|string|max:20',
'permissions' => 'nullable|array',
'permissions.*' => 'string|in:' . implode(',', array_keys(User::getAvailablePermissions())),
], [
'website.regex' => 'O site deve começar com http:// ou https:// e ter um formato válido.',
]);
$data = $request->all();
$data['password'] = \Illuminate\Support\Facades\Hash::make($request->password);
$data['email_verified_at'] = now();
// Handle permissions: admin types get null (all access), others get selected permissions
$selectedType = UserType::where('name', $data['user_type'])->first();
if ($selectedType && $selectedType->is_admin) {
$data['permissions'] = null;
} else {
$data['permissions'] = $request->has('permissions') ? $request->input('permissions') : [];
}
User::create($data);
return redirect()->route('admin.users.index')->with('success', 'Usuário criado com sucesso.');
}
public function edit(User $user)
{
$availablePermissions = User::getAvailablePermissions();
$userTypes = UserType::getAllForSelect();
return view('admin.users.edit', compact('user', 'availablePermissions', 'userTypes'));
}
public function update(Request $request, User $user)
{
$validTypes = UserType::pluck('name')->toArray();
$request->validate([
'name' => 'required|string|max:255',
'user_type' => 'required|string|in:' . implode(',', $validTypes),
'company' => 'nullable|string|max:255',
'website' => 'nullable|string|max:255|regex:/^https?:\/\/.+\..+$/i',
'whatsapp' => 'nullable|string|max:20',
'password' => 'nullable|string|min:8|confirmed',
'permissions' => 'nullable|array',
'permissions.*' => 'string|in:' . implode(',', array_keys(User::getAvailablePermissions())),
], [
'website.regex' => 'O site deve começar com http:// ou https:// e ter um formato válido.',
]);
$data = $request->except('password');
if ($request->filled('password')) {
$data['password'] = \Illuminate\Support\Facades\Hash::make($request->password);
}
// Handle permissions: admin types get null (all access), others get selected permissions
$selectedType = UserType::where('name', $data['user_type'])->first();
if ($selectedType && $selectedType->is_admin) {
$data['permissions'] = null;
} else {
$data['permissions'] = $request->has('permissions') ? $request->input('permissions') : [];
}
$user->update($data);
return redirect()->route('admin.users.index')->with('success', 'Usuário atualizado com sucesso.');
}
public function destroy(User $user)
{
if ($user->id === \Illuminate\Support\Facades\Auth::id()) {
return redirect()->route('admin.users.index')->with('error', 'Você não pode excluir sua própria conta.');
}
$user->delete();
return redirect()->route('admin.users.index')->with('success', 'Usuário excluído com sucesso.');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserType;
use App\Models\User;
use Illuminate\Http\Request;
class UserTypeController extends Controller
{
/**
* Display a listing of user types.
*/
public function index()
{
$userTypes = UserType::orderBy('name')->get();
$userTypes->each(function ($type) {
$type->users_count = User::where('user_type', $type->name)->count();
});
return view('admin.user-types.index', compact('userTypes'));
}
/**
* Show the form for creating a new user type.
*/
public function create()
{
return view('admin.user-types.create');
}
/**
* Store a newly created user type.
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:user_types,name',
'description' => 'nullable|string|max:500',
'is_admin' => 'nullable|boolean',
]);
UserType::create([
'name' => $request->name,
'description' => $request->description,
'is_admin' => $request->has('is_admin'),
'is_protected' => false,
]);
return redirect()->route('admin.user-types.index')->with('success', 'Tipo de usuário criado com sucesso.');
}
/**
* Show the form for editing the specified user type.
*/
public function edit(UserType $userType)
{
return view('admin.user-types.edit', compact('userType'));
}
/**
* Update the specified user type.
*/
public function update(Request $request, UserType $userType)
{
$request->validate([
'name' => 'required|string|max:255|unique:user_types,name,' . $userType->id,
'description' => 'nullable|string|max:500',
'is_admin' => 'nullable|boolean',
]);
$oldName = $userType->name;
$userType->update([
'name' => $request->name,
'description' => $request->description,
'is_admin' => $request->has('is_admin'),
]);
// Update all users with the old type name to the new type name
if ($oldName !== $request->name) {
User::where('user_type', $oldName)->update(['user_type' => $request->name]);
}
return redirect()->route('admin.user-types.index')->with('success', 'Tipo de usuário atualizado com sucesso.');
}
/**
* Remove the specified user type.
*/
public function destroy(UserType $userType)
{
if ($userType->is_protected) {
return redirect()->route('admin.user-types.index')->with('error', 'Este tipo de usuário é protegido e não pode ser excluído.');
}
$userCount = User::where('user_type', $userType->name)->count();
if ($userCount > 0) {
return redirect()->route('admin.user-types.index')->with('error', "Existem {$userCount} usuário(s) com este tipo. Altere o tipo desses usuários antes de excluir.");
}
$userType->delete();
return redirect()->route('admin.user-types.index')->with('success', 'Tipo de usuário excluído com sucesso.');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ConfirmsPasswords;
class ConfirmPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Confirm Password Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password confirmations and
| uses a simple trait to include the behavior. You're free to explore
| this trait and override any functions that require customization.
|
*/
use ConfirmsPasswords;
/**
* Where to redirect users when the intended url fails.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/**
* Validate the email for the given request.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
protected function validateEmail(Request $request)
{
$request->validate([
'email' => 'required|email',
'captcha' => 'required|numeric',
]);
if ($request->captcha != session('captcha_result')) {
throw \Illuminate\Validation\ValidationException::withMessages([
'captcha' => ['O resultado da soma está incorreto.'],
]);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to their home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest')->except('logout');
$this->middleware('auth')->only('logout');
}
/**
* Validate the user login request.
*
* @param \Illuminate\Http\Request $request
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function validateLogin(Request $request)
{
$request->validate([
$this->username() => 'required|string',
'password' => 'required|string',
'captcha' => 'required|numeric',
]);
if ($request->captcha != session('captcha_result')) {
throw \Illuminate\Validation\ValidationException::withMessages([
'captcha' => ['O resultado da soma está incorreto.'],
]);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Setting;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Notification;
use App\Notifications\NewUserRegisteredNotification;
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
/**
* Where to redirect users after registration.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Check if public registration is enabled.
*
* @return bool
*/
protected function isRegistrationEnabled()
{
return Setting::get('public_registration', 'off') === 'on';
}
/**
* Show the application registration form.
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
*/
public function showRegistrationForm()
{
if (!$this->isRegistrationEnabled()) {
return redirect()->route('login')->with('error', 'O registro público está desativado no momento.');
}
return view('auth.register');
}
/**
* Handle a registration request for the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response
*/
public function register(\Illuminate\Http\Request $request)
{
if (!$this->isRegistrationEnabled()) {
return redirect()->route('login')->with('error', 'O registro público está desativado no momento.');
}
$request->validate(['captcha' => 'required|numeric']);
if ($request->captcha != session('captcha_result')) {
throw \Illuminate\Validation\ValidationException::withMessages([
'captcha' => ['O resultado da soma está incorreto.'],
]);
}
$this->validator($request->all())->validate();
event(new \Illuminate\Auth\Events\Registered($user = $this->create($request->all())));
$this->guard()->login($user);
if ($response = $this->registered($request, $user)) {
return $response;
}
return $request->wantsJson()
? new \Illuminate\Http\JsonResponse([], 201)
: redirect($this->redirectPath());
}
/**
* Get a validator for an incoming registration request.
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'company' => ['nullable', 'string', 'max:255'],
'website' => ['nullable', 'string', 'max:255'],
'whatsapp' => ['nullable', 'string', 'max:20'],
]);
}
/**
* Create a new user instance after a valid registration.
*
* @return User
*/
protected function create(array $data)
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'user_type' => 'User', // Force all public registrations to be regular users
'company' => $data['company'] ?? null,
'website' => $data['website'] ?? null,
'whatsapp' => $data['whatsapp'] ?? null,
'permissions' => [],
]);
// Notificar administradores sobre o novo cadastro
$admins = User::where('user_type', 'Admin')->get();
Notification::send($admins, new NewUserRegisteredNotification($user));
return $user;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
/**
* Where to redirect users after resetting their password.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Reset the given user's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
public function reset(Request $request)
{
$request->validate([
'captcha' => 'required|numeric',
]);
if ($request->captcha != session('captcha_result')) {
throw \Illuminate\Validation\ValidationException::withMessages([
'captcha' => ['O resultado da soma está incorreto.'],
]);
}
return parent::reset($request);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\VerifiesEmails;
class VerificationController extends Controller
{
/*
|--------------------------------------------------------------------------
| Email Verification Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling email verification for any
| user that recently registered with the application. Emails may also
| be re-sent if the user didn't receive the original email message.
|
*/
use VerifiesEmails;
/**
* Where to redirect users after verification.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
$this->middleware('signed')->only('verify');
$this->middleware('throttle:6,1')->only('verify', 'resend');
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Auth;
use App\Notifications\FeatureRequestNotification;
use App\Models\Setting;
use App\Models\ContactMessage;
use App\Models\User;
class HomeController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the application dashboard.
*
* @return \Illuminate\Contracts\Support\Renderable
*/
public function index()
{
$metrics = [
'pending_tickets' => ContactMessage::where('read', false)->count(),
'total_contacts' => ContactMessage::count(),
'total_users' => User::where('user_type', 'User')->count(),
];
// Products module metrics (only when module is enabled)
if (module_enabled('products') && class_exists(\App\Modules\Products\Models\Product::class)) {
$metrics['total_products'] = \App\Modules\Products\Models\Product::where('active', true)->count();
}
// Services module metrics (only when module is enabled)
if (module_enabled('services') && class_exists(\App\Modules\Services\Models\Service::class)) {
$metrics['total_services'] = \App\Modules\Services\Models\Service::where('active', true)->count();
}
return view('home', compact('metrics'));
}
/**
* Send a feature request to SysSiteCMS.
*/
public function sendFeatureRequest(Request $request)
{
$request->validate([
'feature_description' => 'required|string|min:10',
]);
$user = Auth::user();
$description = $request->feature_description;
// Send notification to SysSiteCMS email
Notification::route('mail', 'contato@syspanel.com.br')
->notify(new FeatureRequestNotification($user, $description));
return redirect()->back()->with('success', 'Sua solicitação foi enviada com sucesso ao SysSiteCMS! Em breve entraremos em contato.');
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ContactSubject;
use App\Models\ContactMessage;
use App\Models\Page;
use App\Models\Faq;
use App\Models\Download;
use App\Models\Gallery;
use App\Models\GalleryCategory;
use App\Models\News;
use App\Models\Banner;
use App\Models\User;
use App\Notifications\NewContactMessageNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Redirect;
class PageController extends Controller
{
public function index()
{
$page = Page::where('slug', 'index')->where('active', true)->firstOrFail();
$banners = Banner::where('active', true)->orderBy('order')->get();
return view('index', compact('page', 'banners'));
}
public function about()
{
$page = Page::where('slug', 'about')->where('active', true)->firstOrFail();
return view('about', compact('page'));
}
// Note: produtos() is now handled by the Products module (ProductPageController)
// Note: servicos() is now handled by the Services module (ServicePageController)
public function assistentesDigitaisIA()
{
$page = Page::where('slug', 'assistentes-digitais-com-ia')->where('active', true)->firstOrFail();
return view('assistentes-digitais-ia', compact('page'));
}
public function servidoresLinux()
{
$page = Page::where('slug', 'linux-servers')->where('active', true)->firstOrFail();
return view('servidores-linux', compact('page'));
}
public function erp()
{
$page = Page::where('slug', 'erp')->where('active', true)->firstOrFail();
return view('erp', compact('page'));
}
public function fluxoDeCaixaPro()
{
$page = Page::where('slug', 'fluxo-de-caixa-pro')->where('active', true)->firstOrFail();
return view('fluxo-de-caixa-pro', compact('page'));
}
public function downloads()
{
$page = Page::where('slug', 'downloads')->where('active', true)->firstOrFail();
$downloads = Download::where('active', true)->latest()->get();
return view('downloads', compact('downloads', 'page'));
}
public function downloadFile(Download $download)
{
// Only allow download if the download is active
if (!$download->active) {
abort(404, 'Download não encontrado.');
}
$download->increment('download_count');
return response()->download(storage_path('app/public/' . $download->file_path));
}
public function faq()
{
$page = Page::where('slug', 'faq')->where('active', true)->firstOrFail();
$faqs = Faq::where('active', true)->orderBy('order')->get();
return view('faq', compact('faqs', 'page'));
}
public function galleryCategory($slug)
{
$category = GalleryCategory::where('slug', $slug)->where('active', true)->firstOrFail();
$galleries = Gallery::where('active', true)->where('category_id', $category->id)->with('images')->withCount('images')->orderBy('title')->paginate(12);
return view('gallery-category', compact('category', 'galleries'));
}
public function gallery($slug)
{
$gallery = Gallery::where('slug', $slug)->where('active', true)->with('images')->firstOrFail();
return view('gallery-show', compact('gallery'));
}
public function newsIndex()
{
$newsList = News::published()->latest('published_at')->latest()->paginate(9);
return view('news-index', compact('newsList'));
}
public function newsShow($slug)
{
$news = News::where('slug', $slug)->published()->with('images')->firstOrFail();
return view('news-show', compact('news'));
}
/**
* Show a custom dynamic page.
*/
public function show($slug)
{
$page = Page::where('slug', $slug)->where('active', true)->firstOrFail();
return view('page', compact('page'));
}
public function contato()
{
$page = Page::where('slug', 'contato')->where('active', true)->firstOrFail();
$subjects = ContactSubject::where('active', true)->orderBy('name')->get();
return view('contato', compact('subjects', 'page'));
}
public function sendContact(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'subject_id' => 'required|exists:contact_subjects,id',
'message' => 'required|string|min:10',
'whatsapp' => 'nullable|string|max:20|regex:/^[\d\s\(\)\+\-]{10,20}$/',
'company' => 'nullable|string|max:255',
'website' => 'nullable|string|max:255|regex:/^https?:\/\/.+\..+$/i',
'captcha' => 'required|numeric',
], [
'whatsapp.regex' => 'O formato do WhatsApp é inválido.',
'website.regex' => 'O site deve começar com http:// ou https:// e ter um formato válido.',
]);
// Captcha validation with timing-safe comparison
if (!hash_equals((string) session('captcha_result'), (string) $request->captcha)) {
return back()->withErrors(['captcha' => 'O resultado da soma está incorreto.'])->withInput();
}
// Create contact message with explicit fields
$contact = ContactMessage::create([
'name' => $request->name,
'email' => $request->email,
'company' => $request->company,
'website' => $request->website,
'whatsapp' => $request->whatsapp,
'subject_id' => $request->subject_id,
'message' => $request->message,
]);
// Notify administrators
$admins = User::where('user_type', 'Admin')->get();
Notification::send($admins, new NewContactMessageNotification($contact));
return Redirect::route('contato')->with('success', 'Sua mensagem foi enviada com sucesso! Em breve entraremos em contato.');
}
public function politicaPrivacidade()
{
$page = Page::where('slug', 'politica-privacidade')->where('active', true)->first();
return view('politica-privacidade', compact('page'));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use App\Models\Setting;
use Illuminate\Support\Facades\Storage;
class ProfileController extends Controller
{
/**
* Show the form for editing the user profile.
*/
public function edit()
{
$user = Auth::user();
return view('profile.edit', compact('user'));
}
/**
* Update the user profile.
*/
public function update(Request $request)
{
/** @var \App\Models\User $user */
$user = Auth::user();
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpeg,png,jpg,gif,webp'], // Max 2MB
'company' => ['nullable', 'string', 'max:255'],
'website' => ['nullable', 'url', 'max:255'],
'whatsapp' => ['nullable', 'string', 'max:20', 'regex:/^[\d\s\(\)\+\-]{10,20}$/'],
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
], [
'avatar.mimes' => 'O avatar deve ser do tipo: JPEG, PNG, JPG, GIF ou WebP.',
'website.url' => 'O formato do site é inválido.',
'whatsapp.regex' => 'O formato do WhatsApp é inválido.',
]);
$data = $request->except(['password', 'avatar']);
if ($request->filled('password')) {
$data['password'] = Hash::make($request->password);
}
if ($request->hasFile('avatar')) {
// Remove avatar antigo se existir
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$data['avatar'] = $request->file('avatar')->store('avatars', 'public');
}
$user->update($data);
return redirect()->route('profile.edit')->with('success', 'Perfil atualizado com sucesso!');
}
/**
* Show user preferences.
*/
public function preferences()
{
$user = Auth::user();
$preferences = $user->preferences ?? [
'notifications_email' => true,
'notifications_system' => true,
'notifications_new_users' => true,
'notifications_page_actions' => true,
'notifications_gallery_actions' => true,
'notifications_product_actions' => true,
'theme' => 'light'
];
return view('profile.preferences', compact('user', 'preferences'));
}
/**
* Update user preferences.
*/
public function updatePreferences(Request $request)
{
/** @var \App\Models\User $user */
$user = Auth::user();
$user->update([
'preferences' => [
'notifications_email' => $request->has('notifications_email'),
'notifications_system' => $request->has('notifications_system'),
'notifications_new_users' => $request->has('notifications_new_users'),
'notifications_page_actions' => $request->has('notifications_page_actions'),
'notifications_gallery_actions' => $request->has('notifications_gallery_actions'),
'notifications_product_actions' => $request->has('notifications_product_actions'),
'theme' => $request->theme ?? 'light'
]
]);
if ($user->user_type === 'Admin') {
Setting::set('site_maintenance', $request->has('site_maintenance') ? 'on' : 'off', 'general');
Setting::set('whatsapp_floating_button', $request->has('whatsapp_floating_button') ? 'on' : 'off', 'general');
Setting::set('public_registration', $request->has('public_registration') ? 'on' : 'off', 'general');
// Salvar configurações de horário do WhatsApp
Setting::set('whatsapp_start_time', $request->whatsapp_start_time ?? '08:00', 'general');
Setting::set('whatsapp_end_time', $request->whatsapp_end_time ?? '18:00', 'general');
Setting::set('whatsapp_lunch_start', $request->whatsapp_lunch_start, 'general');
Setting::set('whatsapp_lunch_end', $request->whatsapp_lunch_end, 'general');
}
return redirect()->route('profile.preferences')->with('success', 'Preferências atualizadas com sucesso!');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Setting;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class CheckMaintenanceMode
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// Se o modo de manutenção estiver ativado
if (Setting::get('site_maintenance') === 'on') {
// Permite acesso às rotas de login, logout e painel administrativo
if ($request->is('login') || $request->is('logout') || $request->is('admin*') || $request->is('password*')) {
return $next($request);
}
/** @var \App\Models\User $user */
$user = Auth::user();
// Permite que administradores continuem acessando o site público
if ($user && $user->user_type === 'Admin') {
return $next($request);
}
// Para todos os outros usuários ou visitantes, exibe a página de manutenção
return response()->view('errors.maintenance', [], 503);
}
return $next($request);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
class CheckPermission
{
/**
* Handle an incoming request.
* Checks if the authenticated user has the required permission.
* Admin users always pass. User types need the specific permission.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $permission): Response
{
if (!Auth::check()) {
return redirect()->route('login');
}
$user = Auth::user();
if ($user->isAdmin()) {
return $next($request);
}
if ($user->hasPermission($permission)) {
return $next($request);
}
abort(403, 'Acesso negado. Você não tem permissão para acessar esta seção.');
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
class EnsureUserIsAdmin
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && Auth::user()->user_type === 'Admin') {
return $next($request);
}
abort(403, 'Acesso negado. Esta área é restrita a administradores.');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\VisitorLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class TrackVisitor
{
public function handle(Request $request, Closure $next)
{
// Só rastreia requisições GET e que não sejam administrativas ou de sistema
if ($request->isMethod('GET') && !$request->is('admin*') && !$request->is('login') && !$request->is('logout')) {
$ip = $request->ip();
$sessionId = session()->getId();
// Tenta obter localização do cache para não sobrecarregar a API nem atrasar o site
$location = Cache::remember('visitor_location_' . $ip, now()->addDays(7), function () use ($ip) {
try {
// Usando ip-api.com (serviço gratuito limitado)
$response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}?fields=status,country,city,regionName");
if ($response->successful() && $response->json('status') === 'success') {
return $response->json();
}
} catch (\Exception $e) {
Log::warning("Erro ao rastrear localização do IP {$ip}: " . $e->getMessage());
}
return [
'country' => 'Desconhecido',
'city' => 'Desconhecido',
'regionName' => 'Desconhecido'
];
});
// Registra o log
VisitorLog::create([
'ip_address' => $ip,
'country' => $location['country'] ?? 'Desconhecido',
'city' => $location['city'] ?? 'Desconhecido',
'region' => $location['regionName'] ?? 'Desconhecido',
'session_id' => $sessionId,
'user_agent' => $request->userAgent(),
'page_url' => $request->fullUrl()
]);
}
return $next($request);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Models\ContactMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactReplyMail extends Mailable
{
use Queueable, SerializesModels;
public $contact;
public $replyMessage;
/**
* Create a new message instance.
*/
public function __construct(ContactMessage $contact, $replyMessage)
{
$this->contact = $contact;
$this->replyMessage = $replyMessage;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Re: ' . ($this->contact->subject->name ?? 'Contato SysSiteCMS'),
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.contact-reply',
);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PromotionMail extends Mailable
{
use Queueable, SerializesModels;
public $subjectLine;
public $messageBody;
public $buttonText;
public $buttonUrl;
public $productImage;
public $company;
/**
* Create a new message instance.
*/
public function __construct($subject, $message, $btnText = null, $btnUrl = null, $image = null, $company = null)
{
$this->subjectLine = $subject;
$this->messageBody = $message;
$this->buttonText = $btnText;
$this->buttonUrl = $btnUrl;
$this->productImage = $image;
$this->company = $company;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectLine,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.promotion',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Banner extends Model
{
protected $fillable = ['title', 'image', 'link', 'button_text', 'order', 'active'];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContactMessage extends Model
{
protected $fillable = [
'name',
'email',
'company',
'website',
'whatsapp',
'subject_id',
'message',
'read'
];
public function subject()
{
return $this->belongsTo(ContactSubject::class, 'subject_id');
}
public function responses()
{
return $this->hasMany(ContactResponse::class, 'contact_message_id');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContactResponse extends Model
{
protected $fillable = ['contact_message_id', 'user_id', 'message'];
public function message()
{
return $this->belongsTo(ContactMessage::class, 'contact_message_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContactSubject extends Model
{
protected $fillable = ['name', 'active'];
public function messages()
{
return $this->hasMany(ContactMessage::class, 'subject_id');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Download extends Model
{
protected $fillable = ['title', 'description', 'file_path', 'file_type', 'download_count', 'active'];
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Faq extends Model
{
protected $fillable = ['question', 'answer', 'order', 'active'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Gallery extends Model
{
protected $fillable = ['title', 'slug', 'description', 'active', 'category_id'];
public function images()
{
return $this->hasMany(GalleryImage::class)->orderBy('order');
}
public function category()
{
return $this->belongsTo(GalleryCategory::class);
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GalleryCategory extends Model
{
protected $fillable = ['name', 'slug', 'description', 'active'];
public function galleries()
{
return $this->hasMany(Gallery::class, 'category_id')->orderBy('created_at', 'desc');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GalleryImage extends Model
{
protected $fillable = ['gallery_id', 'file_path', 'description', 'order'];
public function gallery()
{
return $this->belongsTo(Gallery::class);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class News extends Model
{
protected $fillable = [
'title',
'slug',
'summary',
'content',
'featured_image',
'author',
'active',
'published_at',
'user_id',
'seo_title',
'seo_description',
'seo_keywords',
];
protected $casts = [
'active' => 'boolean',
'published_at' => 'datetime',
];
/**
* Get the images for the news article.
*/
public function images(): HasMany
{
return $this->hasMany(NewsImage::class)->orderBy('order');
}
/**
* Get the user who created the news article.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Check if the news article is published.
*/
public function isPublished(): bool
{
return $this->active && ($this->published_at === null || $this->published_at->isPast());
}
/**
* Scope to get only published news.
*/
public function scopePublished($query)
{
return $query->where('active', true)
->where(function ($q) {
$q->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NewsImage extends Model
{
protected $fillable = [
'news_id',
'file_path',
'description',
'order',
];
/**
* Get the news article this image belongs to.
*/
public function news(): BelongsTo
{
return $this->belongsTo(News::class);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Page extends Model
{
protected $fillable = [
'slug',
'title',
'content',
'meta_description',
'meta_keywords',
'og_title',
'og_description',
'og_image',
'canonical_url',
'active'
];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value', 'group'];
public static function get($key, $default = null)
{
$setting = self::where('key', $key)->first();
return ($setting && $setting->value !== '' && $setting->value !== null) ? $setting->value : $default;
}
public static function set($key, $value, $group = 'general')
{
return self::updateOrCreate(['key' => $key], ['value' => $value, 'group' => $group]);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'avatar', 'password', 'user_type', 'company', 'website', 'whatsapp', 'preferences', 'permissions'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'preferences' => 'array',
'permissions' => 'array',
];
}
/**
* Check if the user is an Admin.
*/
public function isAdmin(): bool
{
$adminType = \App\Models\UserType::where('is_admin', true)->pluck('name')->toArray();
return in_array($this->user_type, $adminType);
}
/**
* Check if the user is a regular User.
*/
public function isUser(): bool
{
return !$this->isAdmin();
}
/**
* Check if the user has a specific permission.
* Admin types always have all permissions.
*/
public function hasPermission(string $permission): bool
{
if ($this->isAdmin()) {
return true;
}
return in_array($permission, $this->permissions ?? []);
}
/**
* Get all available permissions in the system.
*
* @return array<string, string>
*/
public static function getAvailablePermissions(): array
{
return [
'contacts' => 'Gerenciar Contatos',
'subjects' => 'Gerenciar Assuntos',
'services' => 'Gerenciar Serviços',
'faqs' => 'Gerenciar FAQ',
'downloads' => 'Gerenciar Downloads',
'products' => 'Gerenciar Produtos',
'galleries' => 'Gerenciar Galerias',
'news' => 'Gerenciar Notícias',
'pages' => 'Gerenciar Páginas',
'marketing' => 'Marketing',
'chatbot' => 'Gerenciar Chatbot',
'settings' => 'Dados da Empresa',
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserType extends Model
{
protected $fillable = ['name', 'description', 'is_admin', 'is_protected'];
protected function casts(): array
{
return [
'is_admin' => 'boolean',
'is_protected' => 'boolean',
];
}
/**
* Get the users with this type.
*/
public function users()
{
return User::where('user_type', $this->name);
}
/**
* Get all user types as a key-value array for select fields.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getAllForSelect()
{
return self::orderBy('name')->get();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VisitorLog extends Model
{
protected $fillable = [
'ip_address',
'country',
'city',
'region',
'session_id',
'user_agent',
'page_url'
];
}
@@ -0,0 +1,277 @@
<?php
namespace App\Modules\Chatbot\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Chatbot\Models\ChatbotKnowledge;
use App\Modules\Chatbot\Models\ChatbotRule;
use App\Modules\Chatbot\Models\ChatbotConversation;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use App\Notifications\FeatureRequestNotification;
class ChatbotController extends Controller
{
/**
* Display the knowledge base listing.
*/
public function index(): \Illuminate\View\View
{
$knowledgeEntries = ChatbotKnowledge::orderBy('category')
->orderBy('title')
->paginate(20);
$categories = ChatbotKnowledge::getCategories();
return view('chatbot::admin.index', compact('knowledgeEntries', 'categories'));
}
/**
* Show the form for creating a new knowledge entry.
*/
public function create(): \Illuminate\View\View
{
$categories = ChatbotKnowledge::getCategories();
return view('chatbot::admin.create', compact('categories'));
}
/**
* Store a newly created knowledge entry.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'restrictions' => 'nullable|string',
'category' => 'required|string|in:' . implode(',', array_keys(ChatbotKnowledge::getCategories())),
]);
$validated['active'] = $request->has('active');
ChatbotKnowledge::create($validated);
return redirect()
->route('admin.chatbot.index')
->with('success', 'Informação adicionada à base de conhecimento com sucesso!');
}
/**
* Show the form for editing a knowledge entry.
*/
public function edit(ChatbotKnowledge $chatbot_knowledge): \Illuminate\View\View
{
$entry = $chatbot_knowledge;
$categories = ChatbotKnowledge::getCategories();
return view('chatbot::admin.edit', compact('entry', 'categories'));
}
/**
* Update a knowledge entry.
*/
public function update(Request $request, ChatbotKnowledge $chatbot_knowledge): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'restrictions' => 'nullable|string',
'category' => 'required|string|in:' . implode(',', array_keys(ChatbotKnowledge::getCategories())),
]);
$validated['active'] = $request->has('active');
$chatbot_knowledge->update($validated);
return redirect()
->route('admin.chatbot.index')
->with('success', 'Informação atualizada com sucesso!');
}
/**
* Delete a knowledge entry.
*/
public function destroy(ChatbotKnowledge $chatbot_knowledge): \Illuminate\Http\RedirectResponse
{
$chatbot_knowledge->delete();
return redirect()
->route('admin.chatbot.index')
->with('success', 'Informação removida da base de conhecimento.');
}
/**
* Display conversations listing with visitor contacts.
*/
public function conversations(): \Illuminate\View\View
{
$conversations = ChatbotConversation::with('messages')
->whereNotNull('visitor_name')
->orderBy('created_at', 'desc')
->paginate(20);
return view('chatbot::admin.conversations', compact('conversations'));
}
/**
* Display a specific conversation with all messages.
*/
public function showConversation(ChatbotConversation $conversation): \Illuminate\View\View
{
$conversation->load('messages');
return view('chatbot::admin.conversation-show', compact('conversation'));
}
/**
* Delete a conversation.
*/
public function destroyConversation(ChatbotConversation $conversation): \Illuminate\Http\RedirectResponse
{
$conversation->delete();
return redirect()
->route('admin.chatbot.conversations')
->with('success', 'Conversa removida com sucesso.');
}
/**
* Display chatbot settings.
*/
public function settings(): \Illuminate\View\View
{
$settings = [
'chatbot_enabled' => Setting::get('chatbot_enabled', 'off'),
'chatbot_provider' => Setting::get('chatbot_provider', 'openrouter'),
'chatbot_openrouter_api_key' => Setting::get('chatbot_openrouter_api_key'),
'chatbot_openrouter_model' => Setting::get('chatbot_openrouter_model', 'openai/gpt-3.5-turbo'),
'chatbot_llamacpp_base_url' => Setting::get('chatbot_llamacpp_base_url', 'http://172.17.0.1:8080'),
'chatbot_llamacpp_model' => Setting::get('chatbot_llamacpp_model', ''),
'chatbot_welcome_message' => Setting::get('chatbot_welcome_message', 'Olá! Como posso ajudar você hoje?'),
'chatbot_custom_instructions' => Setting::get('chatbot_custom_instructions'),
'chatbot_max_tokens' => Setting::get('chatbot_max_tokens', '500'),
'chatbot_temperature' => Setting::get('chatbot_temperature', '0.7'),
];
return view('chatbot::admin.settings', compact('settings'));
}
/**
* Update chatbot settings.
*/
public function updateSettings(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'chatbot_enabled' => 'nullable|in:on,off',
'chatbot_provider' => 'required|in:openrouter,llamacpp',
'chatbot_openrouter_api_key' => 'nullable|string|max:255',
'chatbot_openrouter_model' => 'nullable|string|max:255',
'chatbot_llamacpp_base_url' => 'nullable|string|max:255',
'chatbot_llamacpp_model' => 'nullable|string|max:255',
'chatbot_welcome_message' => 'required|string|max:500',
'chatbot_custom_instructions' => 'nullable|string',
'chatbot_max_tokens' => 'required|integer|min:50|max:2000',
'chatbot_temperature' => 'required|numeric|min:0|max:2',
]);
foreach ($validated as $key => $value) {
if ($key === 'chatbot_enabled') {
Setting::set($key, $value ?? 'off', 'chatbot');
} else {
Setting::set($key, $value, 'chatbot');
}
}
// Ensure enabled is set properly when checkbox is not checked
if (!$request->has('chatbot_enabled')) {
Setting::set('chatbot_enabled', 'off', 'chatbot');
}
return redirect()
->route('admin.chatbot.settings')
->with('success', 'Configurações do chatbot atualizadas com sucesso!');
}
/**
* Display the chatbot rules listing.
*/
public function rulesIndex(): \Illuminate\View\View
{
$rules = ChatbotRule::orderBy('order')
->orderBy('id')
->paginate(30);
return view('chatbot::admin.rules.index', compact('rules'));
}
/**
* Show the form for creating a new chatbot rule.
*/
public function rulesCreate(): \Illuminate\View\View
{
return view('chatbot::admin.rules.create');
}
/**
* Store a newly created chatbot rule.
*/
public function rulesStore(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string|max:2000',
'order' => 'nullable|integer|min:0|max:9999',
]);
$validated['active'] = $request->has('active');
$validated['order'] = $validated['order'] ?? 0;
ChatbotRule::create($validated);
return redirect()
->route('admin.chatbot.rules.index')
->with('success', 'Regra criada com sucesso! A IA passará a segui-la nas próximas conversas.');
}
/**
* Show the form for editing a chatbot rule.
*/
public function rulesEdit(ChatbotRule $rule): \Illuminate\View\View
{
return view('chatbot::admin.rules.edit', compact('rule'));
}
/**
* Update a chatbot rule.
*/
public function rulesUpdate(Request $request, ChatbotRule $rule): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string|max:2000',
'order' => 'nullable|integer|min:0|max:9999',
]);
$validated['active'] = $request->has('active');
$validated['order'] = $validated['order'] ?? 0;
$rule->update($validated);
return redirect()
->route('admin.chatbot.rules.index')
->with('success', 'Regra atualizada com sucesso!');
}
/**
* Delete a chatbot rule.
*/
public function rulesDestroy(ChatbotRule $rule): \Illuminate\Http\RedirectResponse
{
$rule->delete();
return redirect()
->route('admin.chatbot.rules.index')
->with('success', 'Regra removida com sucesso.');
}
}
@@ -0,0 +1,259 @@
<?php
namespace App\Modules\Chatbot\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Models\User;
use App\Modules\Chatbot\Models\ChatbotConversation;
use App\Modules\Chatbot\Models\ChatbotMessage;
use App\Modules\Chatbot\Notifications\ChatbotConversationNotification;
use App\Modules\Chatbot\Services\OpenRouterService;
use App\Modules\Chatbot\Services\LlamaCppService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
class ChatbotApiController extends Controller
{
/**
* Register visitor information before starting the chat.
*/
public function registerVisitor(Request $request): JsonResponse
{
// Check if chatbot is enabled
if (Setting::get('chatbot_enabled', 'off') !== 'on') {
return response()->json([
'success' => false,
'message' => 'O assistente virtual está indisponível no momento.',
], 403);
}
$validator = Validator::make($request->all(), [
'session_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
'name' => 'required|string|max:255',
'phone' => 'required|string|max:30',
'city' => 'required|string|max:255',
], [
'session_id.regex' => 'ID de sessão inválido.',
'name.required' => 'Por favor, informe seu nome.',
'phone.required' => 'Por favor, informe seu telefone.',
'city.required' => 'Por favor, informe sua cidade.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Preencha todos os campos obrigatórios.',
'errors' => $validator->errors(),
], 422);
}
$sessionId = $request->input('session_id');
// Get or create conversation and save visitor info
$conversation = ChatbotConversation::getOrCreateBySession($sessionId);
$conversation->update([
'visitor_name' => trim($request->input('name')),
'visitor_phone' => trim($request->input('phone')),
'visitor_city' => trim($request->input('city')),
]);
// Send notification to admins
$adminTypes = \App\Models\UserType::where('is_admin', true)->pluck('name')->toArray();
$admins = User::whereIn('user_type', $adminTypes)->get();
if ($admins->isNotEmpty()) {
Notification::send($admins, new ChatbotConversationNotification($conversation));
}
return response()->json([
'success' => true,
'message' => 'Dados registrados com sucesso.',
]);
}
/**
* Handle an incoming chat message from the public widget.
*/
public function sendMessage(Request $request): JsonResponse
{
// Check if chatbot is enabled
if (Setting::get('chatbot_enabled', 'off') !== 'on') {
return response()->json([
'success' => false,
'message' => 'O assistente virtual está indisponível no momento.',
], 403);
}
$validator = Validator::make($request->all(), [
'message' => 'required|string|max:1000',
'session_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
], [
'session_id.regex' => 'ID de sessão inválido.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Mensagem inválida.',
'errors' => $validator->errors(),
], 422);
}
$sessionId = $request->input('session_id');
$userMessage = trim($request->input('message'));
// Get or create conversation
$conversation = ChatbotConversation::getOrCreateBySession($sessionId);
// Check if conversation is ended
if ($conversation->isEnded()) {
return response()->json([
'success' => false,
'message' => 'Esta conversa foi finalizada. Inicie uma nova conversa.',
'conversation_ended' => true,
], 403);
}
// Save user message
ChatbotMessage::create([
'conversation_id' => $conversation->id,
'role' => 'user',
'content' => $userMessage,
]);
// Get conversation context
$context = $conversation->getContextForApi(20);
// Call the configured AI provider (OpenRouter or llama.cpp)
$provider = Setting::get('chatbot_provider', 'openrouter');
if ($provider === 'llamacpp') {
$aiService = new LlamaCppService();
} else {
$aiService = new OpenRouterService();
}
$result = $aiService->chat($context, $conversation);
// Save assistant message with token usage
ChatbotMessage::create([
'conversation_id' => $conversation->id,
'role' => 'assistant',
'content' => $result['content'],
'tokens_used' => $result['tokens_used'],
]);
return response()->json([
'success' => true,
'message' => $result['content'],
]);
}
/**
* End a conversation.
*/
public function endConversation(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'session_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
], [
'session_id.regex' => 'ID de sessão inválido.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Sessão inválida.',
], 422);
}
$sessionId = $request->input('session_id');
$conversation = ChatbotConversation::where('session_id', $sessionId)->first();
if (!$conversation) {
return response()->json([
'success' => false,
'message' => 'Conversa não encontrada.',
], 404);
}
if ($conversation->isEnded()) {
return response()->json([
'success' => true,
'message' => 'Conversa já foi finalizada.',
]);
}
$conversation->update([
'status' => 'ended',
'ended_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Conversa finalizada com sucesso.',
]);
}
/**
* Submit a rating for a conversation.
*/
public function rateConversation(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'session_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'nullable|string|max:500',
], [
'session_id.regex' => 'ID de sessão inválido.',
'rating.required' => 'Por favor, selecione uma avaliação.',
'rating.min' => 'A avaliação mínima é 1 estrela.',
'rating.max' => 'A avaliação máxima é 5 estrelas.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Avaliação inválida.',
'errors' => $validator->errors(),
], 422);
}
$sessionId = $request->input('session_id');
$conversation = ChatbotConversation::where('session_id', $sessionId)->first();
if (!$conversation) {
return response()->json([
'success' => false,
'message' => 'Conversa não encontrada.',
], 404);
}
$conversation->update([
'rating' => $request->input('rating'),
'rating_comment' => $request->input('comment'),
]);
return response()->json([
'success' => true,
'message' => 'Avaliação registrada. Obrigado pelo feedback!',
]);
}
/**
* Get the initial welcome message and chatbot status.
*/
public function getStatus(): JsonResponse
{
$enabled = Setting::get('chatbot_enabled', 'off') === 'on';
$welcomeMessage = Setting::get('chatbot_welcome_message', 'Olá! Como posso ajudar você hoje?');
return response()->json([
'enabled' => $enabled,
'welcome_message' => $welcomeMessage,
]);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Modules\Chatbot\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ChatbotConversation extends Model
{
protected $table = 'chatbot_conversations';
protected $fillable = [
'session_id',
'visitor_name',
'visitor_email',
'visitor_phone',
'visitor_city',
'status',
'rating',
'rating_comment',
'ended_at',
];
protected $casts = [
'rating' => 'integer',
'ended_at' => 'datetime',
];
/**
* Get the messages for this conversation.
*/
public function messages(): HasMany
{
return $this->hasMany(ChatbotMessage::class, 'conversation_id');
}
/**
* Get or create a conversation by session ID.
*/
public static function getOrCreateBySession(string $sessionId): self
{
return self::firstOrCreate(
['session_id' => $sessionId],
['session_id' => $sessionId]
);
}
/**
* Check if the conversation is active.
*/
public function isActive(): bool
{
return $this->status === 'active';
}
/**
* Check if the conversation is ended.
*/
public function isEnded(): bool
{
return $this->status === 'ended';
}
/**
* Get the conversation context as an array of messages for the API.
*/
public function getContextForApi(int $maxMessages = 20): array
{
$messages = $this->messages()
->orderBy('created_at', 'asc')
->take($maxMessages)
->get(['role', 'content']);
return $messages->map(fn($m) => [
'role' => $m->role,
'content' => $m->content,
])->toArray();
}
/**
* Get the rating as star display text.
*/
public function getRatingStarsAttribute(): string
{
if (!$this->rating) {
return '—';
}
$stars = str_repeat('★', $this->rating) . str_repeat('☆', 5 - $this->rating);
return $stars;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Modules\Chatbot\Models;
use Illuminate\Database\Eloquent\Model;
class ChatbotKnowledge extends Model
{
protected $table = 'chatbot_knowledge';
protected $fillable = [
'title',
'content',
'restrictions',
'category',
'active',
];
protected $casts = [
'active' => 'boolean',
];
/**
* Available categories for knowledge base entries.
*/
public static function getCategories(): array
{
return [
'company_info' => 'Informações da Empresa',
'products' => 'Produtos',
'services' => 'Serviços',
'faq' => 'Perguntas Frequentes',
'general' => 'Geral',
];
}
/**
* Scope to get only active entries.
*/
public function scopeActive($query)
{
return $query->where('active', true);
}
/**
* Get all knowledge as a formatted text for the system prompt.
*/
public static function getFormattedKnowledge(): string
{
$entries = self::active()->orderBy('category')->orderBy('title')->get();
if ($entries->isEmpty()) {
return '';
}
$grouped = $entries->groupBy('category');
$categories = self::getCategories();
$output = '';
foreach ($grouped as $category => $items) {
$label = $categories[$category] ?? $category;
$output .= "\n## {$label}\n";
foreach ($items as $item) {
$output .= "- **{$item->title}**: {$item->content}\n";
if ($item->restrictions) {
$output .= " - ⚠️ Restrição: {$item->restrictions}\n";
}
}
}
return $output;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Modules\Chatbot\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatbotMessage extends Model
{
protected $table = 'chatbot_messages';
protected $fillable = [
'conversation_id',
'role',
'content',
'tokens_used',
];
/**
* Get the conversation this message belongs to.
*/
public function conversation(): BelongsTo
{
return $this->belongsTo(ChatbotConversation::class, 'conversation_id');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Modules\Chatbot\Models;
use Illuminate\Database\Eloquent\Model;
class ChatbotRule extends Model
{
protected $table = 'chatbot_rules';
protected $fillable = [
'title',
'content',
'active',
'order',
];
protected $casts = [
'active' => 'boolean',
'order' => 'integer',
];
/**
* Scope to get only active rules.
*/
public function scopeActive($query)
{
return $query->where('active', true);
}
/**
* Get all active rules as a formatted text block for the system prompt.
*
* Returns an empty string when no active rule exists, so the prompt
* stays clean and the AI behaves as before.
*/
public static function getFormattedRules(): string
{
$rules = self::active()->orderBy('order')->orderBy('id')->get();
if ($rules->isEmpty()) {
return '';
}
$output = '';
foreach ($rules as $rule) {
$output .= "- **{$rule->title}**: {$rule->content}\n";
}
return trim($output);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Modules\Chatbot\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Modules\Chatbot\Models\ChatbotConversation;
class ChatbotConversationNotification extends Notification
{
use Queueable;
protected $conversation;
/**
* Create a new notification instance.
*/
public function __construct(ChatbotConversation $conversation)
{
$this->conversation = $conversation;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Nova Conversa do Chatbot - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Um visitante iniciou uma conversa no chatbot do site.')
->line('**Nome:** ' . ($this->conversation->visitor_name ?? 'Não informado'))
->line('**Telefone:** ' . ($this->conversation->visitor_phone ?? 'Não informado'))
->line('**Cidade:** ' . ($this->conversation->visitor_city ?? 'Não informado'))
->action('Ver Conversa', route('admin.chatbot.conversation.show', $this->conversation->id))
->line('Acesse o painel para ver o histórico completo da conversa.');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'conversation_id' => $this->conversation->id,
'name' => $this->conversation->visitor_name ?? 'Visitante',
'email' => $this->conversation->visitor_phone ?? '',
'message' => 'Nova conversa no chatbot: ' . ($this->conversation->visitor_name ?? 'Visitante') . ' de ' . ($this->conversation->visitor_city ?? '?'),
'link' => route('admin.chatbot.conversation.show', $this->conversation->id)
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Chatbot\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
/**
* Chatbot Module Service Provider
*
* Registers all Chatbot module resources when the module is enabled:
* - Routes (public API and admin)
* - Views (namespaced as 'chatbot::')
*/
class ChatbotServiceProvider extends ServiceProvider
{
/**
* The module identifier.
*/
protected string $moduleName = 'chatbot';
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Register module views
$this->loadViewsFrom(
__DIR__ . '/../resources/views',
'chatbot'
);
// Register module routes
Route::middleware(['web'])
->group(__DIR__ . '/../routes/web.php');
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Modules\Chatbot\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Models\Setting;
use App\Modules\Chatbot\Models\ChatbotConversation;
use App\Modules\Chatbot\Models\ChatbotKnowledge;
use App\Modules\Chatbot\Models\ChatbotRule;
class LlamaCppService
{
/**
* Send a chat message to a local llama.cpp server and get a response.
*
* @param array $messages Array of messages with 'role' and 'content'
* @return array ['content' => string, 'tokens_used' => int|null]
*/
public function chat(array $messages, ?ChatbotConversation $conversation = null): array
{
$baseUrl = Setting::get('chatbot_llamacpp_base_url', 'http://172.17.0.1:8080');
$model = Setting::get('chatbot_llamacpp_model', '');
if (empty($baseUrl)) {
Log::warning('Chatbot: llama.cpp base URL not configured.');
return [
'content' => 'Desculpe, o assistente virtual local não está configurado corretamente. Por favor, entre em contato com a empresa.',
'tokens_used' => null,
];
}
$systemPrompt = $this->buildSystemPrompt($conversation);
$payload = [
'messages' => array_merge(
[['role' => 'system', 'content' => $systemPrompt]],
$messages
),
'max_tokens' => (int) Setting::get('chatbot_max_tokens', 500),
'temperature' => (float) Setting::get('chatbot_temperature', 0.7),
'stream' => false,
];
// Add model name if specified (some llama.cpp setups use it)
if (!empty($model)) {
$payload['model'] = $model;
}
$url = rtrim($baseUrl, '/') . '/v1/chat/completions';
try {
$response = Http::timeout(120)
->withHeaders([
'Content-Type' => 'application/json',
])
->post($url, $payload);
if ($response->successful()) {
$data = $response->json();
$tokensUsed = $data['usage']['total_tokens'] ?? null;
// Update cumulative token counter
if ($tokensUsed) {
$currentTotal = (int) Setting::get('chatbot_total_tokens_used', 0);
Setting::set('chatbot_total_tokens_used', $currentTotal + $tokensUsed, 'chatbot');
}
return [
'content' => $data['choices'][0]['message']['content'] ?? 'Desculpe, não consegui processar sua mensagem.',
'tokens_used' => $tokensUsed,
];
}
Log::error('Chatbot: llama.cpp API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'content' => 'Desculpe, estou com dificuldades para responder no momento. Tente novamente em instantes.',
'tokens_used' => null,
];
} catch (\Exception $e) {
Log::error('Chatbot: Exception calling llama.cpp API', [
'message' => $e->getMessage(),
'url' => $url,
]);
return [
'content' => 'Desculpe, ocorreu um erro de conexão com o servidor local. Tente novamente em instantes.',
'tokens_used' => null,
];
}
}
/**
* Build the system prompt with company knowledge.
*/
protected function buildSystemPrompt(?ChatbotConversation $conversation = null): string
{
$companyName = Setting::get('company_name', 'Nossa Empresa');
$companyEmail = Setting::get('company_email');
$companyPhone = Setting::get('company_phone');
$companyAddress = Setting::get('company_address');
$companyHours = Setting::get('company_hours');
$customInstructions = Setting::get('chatbot_custom_instructions', '');
$prompt = "Você é um assistente virtual de atendimento ao cliente da empresa **{$companyName}**.\n\n";
$prompt .= "## Regras Importantes:\n";
$prompt .= "- Responda APENAS sobre a empresa {$companyName}, seus produtos, serviços e informações relacionadas.\n";
$prompt .= "- Seja sempre educado, profissional e prestativo.\n";
$prompt .= "- Responda em português do Brasil.\n";
$prompt .= "- Se não souber a resposta, diga que não possui essa informação e sugira entrar em contato pelo telefone ou e-mail da empresa.\n";
$prompt .= "- NUNCA invente informações que não foram fornecidas na base de conhecimento.\n";
$prompt .= "- NUNCA forneça informações sobre concorrentes ou outras empresas.\n";
$prompt .= "- NUNCA recomende ou sugira que o visitante faça pesquisas na internet (ex: \"pesquise no Google\", \"busque online\", \"procure no site\", \"acesse o link\", etc.). Toda informação deve ser fornecida diretamente por você com base exclusivamente no conteúdo da empresa {$companyName}.\n";
$prompt .= "- NUNCA forneça links, URLs ou endereços de sites externos, redes sociais ou buscadores. Utilize apenas os canais de contato oficiais da empresa (telefone, e-mail, WhatsApp ou endereço físico) listados abaixo.\n";
$prompt .= "- NUNCA diga frases como \"você pode pesquisar\", \"procure saber\", \"verifique na internet\", \"consulte online\" ou qualquer variação que incentive o visitante a buscar informação fora deste chat.\n";
$prompt .= "- Mantenha as respostas concisas e objetivas.\n\n";
if ($companyEmail) {
$prompt .= "- E-mail de contato: {$companyEmail}\n";
}
if ($companyPhone) {
$prompt .= "- Telefone de contato: {$companyPhone}\n";
}
if ($companyAddress) {
$prompt .= "- Endereço: {$companyAddress}\n";
}
if ($companyHours) {
$prompt .= "- Horário de funcionamento: {$companyHours}\n";
}
$prompt .= "\n";
// Add visitor info if available
if ($conversation && $conversation->visitor_name) {
$prompt .= "## Dados do Visitante:\n";
$prompt .= "- **Nome**: {$conversation->visitor_name}\n";
if ($conversation->visitor_phone) {
$prompt .= "- **Telefone**: {$conversation->visitor_phone}\n";
}
if ($conversation->visitor_city) {
$prompt .= "- **Cidade**: {$conversation->visitor_city}\n";
}
$prompt .= "\nSempre cumprimente o visitante pelo nome e personalize o atendimento.\n";
$prompt .= "\n";
}
// Add knowledge base entries
$knowledge = ChatbotKnowledge::getFormattedKnowledge();
if ($knowledge) {
$prompt .= "## Base de Conhecimento da Empresa:\n";
$prompt .= $knowledge;
}
// Add configurable AI rules (admin-defined behavior constraints)
$rules = ChatbotRule::getFormattedRules();
if ($rules) {
$prompt .= "\n## Regras Obrigatórias de Comportamento:\n";
$prompt .= "As regras abaixo foram definidas pela administração e DEVEM ser seguidas rigorosamente em TODAS as respostas:\n";
$prompt .= $rules . "\n";
}
// Add custom instructions
if ($customInstructions) {
$prompt .= "\n## Instruções Adicionais:\n{$customInstructions}\n";
}
return $prompt;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Modules\Chatbot\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Models\Setting;
use App\Modules\Chatbot\Models\ChatbotConversation;
use App\Modules\Chatbot\Models\ChatbotKnowledge;
use App\Modules\Chatbot\Models\ChatbotRule;
class OpenRouterService
{
/**
* Send a chat message to OpenRouter API and get a response.
*
* @param array $messages Array of messages with 'role' and 'content'
* @return array ['content' => string, 'tokens_used' => int|null]
*/
public function chat(array $messages, ?ChatbotConversation $conversation = null): array
{
$apiKey = Setting::get('chatbot_openrouter_api_key');
$model = Setting::get('chatbot_openrouter_model', 'openai/gpt-3.5-turbo');
if (empty($apiKey)) {
Log::warning('Chatbot: OpenRouter API key not configured.');
return [
'content' => 'Desculpe, o assistente virtual não está configurado corretamente. Por favor, entre em contato com a empresa.',
'tokens_used' => null,
];
}
$systemPrompt = $this->buildSystemPrompt($conversation);
$payload = [
'model' => $model,
'messages' => array_merge(
[['role' => 'system', 'content' => $systemPrompt]],
$messages
),
'max_tokens' => (int) Setting::get('chatbot_max_tokens', 500),
'temperature' => (float) Setting::get('chatbot_temperature', 0.7),
];
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
'HTTP-Referer' => config('app.url'),
'X-Title' => 'SysSiteCMS Chatbot',
])->post('https://openrouter.ai/api/v1/chat/completions', $payload);
if ($response->successful()) {
$data = $response->json();
$tokensUsed = $data['usage']['total_tokens'] ?? null;
// Update cumulative token counter
if ($tokensUsed) {
$currentTotal = (int) Setting::get('chatbot_total_tokens_used', 0);
Setting::set('chatbot_total_tokens_used', $currentTotal + $tokensUsed, 'chatbot');
}
return [
'content' => $data['choices'][0]['message']['content'] ?? 'Desculpe, não consegui processar sua mensagem.',
'tokens_used' => $tokensUsed,
];
}
Log::error('Chatbot: OpenRouter API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'content' => 'Desculpe, estou com dificuldades para responder no momento. Tente novamente em instantes.',
'tokens_used' => null,
];
} catch (\Exception $e) {
Log::error('Chatbot: Exception calling OpenRouter API', [
'message' => $e->getMessage(),
]);
return [
'content' => 'Desculpe, ocorreu um erro de conexão. Tente novamente em instantes.',
'tokens_used' => null,
];
}
}
/**
* Build the system prompt with company knowledge.
*/
protected function buildSystemPrompt(?ChatbotConversation $conversation = null): string
{
$companyName = Setting::get('company_name', 'Nossa Empresa');
$companyEmail = Setting::get('company_email');
$companyPhone = Setting::get('company_phone');
$companyAddress = Setting::get('company_address');
$companyHours = Setting::get('company_hours');
$customInstructions = Setting::get('chatbot_custom_instructions', '');
$prompt = "Você é um assistente virtual de atendimento ao cliente da empresa **{$companyName}**.\n\n";
$prompt .= "## Regras Importantes:\n";
$prompt .= "- Responda APENAS sobre a empresa {$companyName}, seus produtos, serviços e informações relacionadas.\n";
$prompt .= "- Seja sempre educado, profissional e prestativo.\n";
$prompt .= "- Responda em português do Brasil.\n";
$prompt .= "- Se não souber a resposta, diga que não possui essa informação e sugira entrar em contato pelo telefone ou e-mail da empresa.\n";
$prompt .= "- NUNCA invente informações que não foram fornecidas na base de conhecimento.\n";
$prompt .= "- NUNCA forneça informações sobre concorrentes ou outras empresas.\n";
$prompt .= "- NUNCA recomende ou sugira que o visitante faça pesquisas na internet (ex: \"pesquise no Google\", \"busque online\", \"procure no site\", \"acesse o link\", etc.). Toda informação deve ser fornecida diretamente por você com base exclusivamente no conteúdo da empresa {$companyName}.\n";
$prompt .= "- NUNCA forneça links, URLs ou endereços de sites externos, redes sociais ou buscadores. Utilize apenas os canais de contato oficiais da empresa (telefone, e-mail, WhatsApp ou endereço físico) listados abaixo.\n";
$prompt .= "- NUNCA diga frases como \"você pode pesquisar\", \"procure saber\", \"verifique na internet\", \"consulte online\" ou qualquer variação que incentive o visitante a buscar informação fora deste chat.\n";
$prompt .= "- Mantenha as respostas concisas e objetivas.\n\n";
if ($companyEmail) {
$prompt .= "- E-mail de contato: {$companyEmail}\n";
}
if ($companyPhone) {
$prompt .= "- Telefone de contato: {$companyPhone}\n";
}
if ($companyAddress) {
$prompt .= "- Endereço: {$companyAddress}\n";
}
if ($companyHours) {
$prompt .= "- Horário de funcionamento: {$companyHours}\n";
}
$prompt .= "\n";
// Add visitor info if available
if ($conversation && $conversation->visitor_name) {
$prompt .= "## Dados do Visitante:\n";
$prompt .= "- **Nome**: {$conversation->visitor_name}\n";
if ($conversation->visitor_phone) {
$prompt .= "- **Telefone**: {$conversation->visitor_phone}\n";
}
if ($conversation->visitor_city) {
$prompt .= "- **Cidade**: {$conversation->visitor_city}\n";
}
$prompt .= "\nSempre cumprimente o visitante pelo nome e personalize o atendimento.\n";
$prompt .= "\n";
}
// Add knowledge base entries
$knowledge = ChatbotKnowledge::getFormattedKnowledge();
if ($knowledge) {
$prompt .= "## Base de Conhecimento da Empresa:\n";
$prompt .= $knowledge;
}
// Add configurable AI rules (admin-defined behavior constraints)
$rules = ChatbotRule::getFormattedRules();
if ($rules) {
$prompt .= "\n## Regras Obrigatórias de Comportamento:\n";
$prompt .= "As regras abaixo foram definidas pela administração e DEVEM ser seguidas rigorosamente em TODAS as respostas:\n";
$prompt .= $rules . "\n";
}
// Add custom instructions
if ($customInstructions) {
$prompt .= "\n## Instruções Adicionais:\n{$customInstructions}\n";
}
return $prompt;
}
}
@@ -0,0 +1,126 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Conversa com ' . $conversation->visitor_name)
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Conversa com {{ $conversation->visitor_name }}
</h1>
<a href="{{ route('admin.chatbot.conversations') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Voltar
</a>
</div>
<!-- Visitor Info Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-person me-1"></i>Dados do Visitante
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<p class="mb-1 text-muted small">Nome</p>
<p class="font-weight-bold">{{ $conversation->visitor_name }}</p>
</div>
<div class="col-md-3">
<p class="mb-1 text-muted small">Telefone</p>
<p>
@if($conversation->visitor_phone)
<a href="tel:{{ $conversation->visitor_phone }}">{{ $conversation->visitor_phone }}</a>
<a href="https://wa.me/55{{ preg_replace('/\D/', '', $conversation->visitor_phone) }}" target="_blank" class="btn btn-sm btn-success ml-2">
<i class="bi bi-whatsapp me-1"></i>WhatsApp
</a>
@else
<span class="text-muted">—</span>
@endif
</p>
</div>
<div class="col-md-3">
<p class="mb-1 text-muted small">Cidade</p>
<p>{{ $conversation->visitor_city ?? '—' }}</p>
</div>
<div class="col-md-3">
<p class="mb-1 text-muted small">Status</p>
<p>
@if($conversation->status === 'active')
<span class="badge badge-success">
<i class="bi bi-chat-dots me-1"></i>Ativa
</span>
@else
<span class="badge badge-secondary">
<i class="bi bi-check-circle me-1"></i>Finalizada
</span>
@if($conversation->ended_at)
<br><small class="text-muted">{{ $conversation->ended_at->format('d/m/Y H:i:s') }}</small>
@endif
@endif
</p>
</div>
</div>
<div class="row">
<div class="col-md-3">
<p class="mb-1 text-muted small">Data de Início</p>
<p>{{ $conversation->created_at->format('d/m/Y H:i:s') }}</p>
</div>
<div class="col-md-3">
<p class="mb-1 text-muted small">Total de Mensagens</p>
<p>{{ $conversation->messages->count() }}</p>
</div>
<div class="col-md-6">
<p class="mb-1 text-muted small">Avaliação do Atendimento</p>
<p>
@if($conversation->rating)
<span style="color: #f5a623; font-size: 1.3rem; letter-spacing: 2px;">
{{ str_repeat('★', $conversation->rating) }}{{ str_repeat('☆', 5 - $conversation->rating) }}
</span>
<span class="ml-2">({{ $conversation->rating }}/5)</span>
@if($conversation->rating_comment)
<br><span class="text-muted"><i class="bi bi-chat-quote me-1"></i>"{{ $conversation->rating_comment }}"</span>
@endif
@else
<span class="text-muted">Não avaliada</span>
@endif
</p>
</div>
</div>
</div>
</div>
<!-- Chat Messages -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-chat-dots me-1"></i>Histórico de Mensagens
</h6>
</div>
<div class="card-body p-0">
<div class="p-3" style="max-height: 500px; overflow-y: auto;">
@forelse($conversation->messages as $message)
<div class="d-flex mb-3 {{ $message->role === 'user' ? 'justify-content-end' : '' }}">
<div class="{{ $message->role === 'user' ? 'bg-primary text-white' : 'bg-light' }} rounded p-3" style="max-width: 75%;">
<div class="small font-weight-bold mb-1 {{ $message->role === 'user' ? 'text-white-50' : 'text-muted' }}">
@if($message->role === 'user')
<i class="bi bi-person me-1"></i>{{ $conversation->visitor_name }}
@elseif($message->role === 'assistant')
<i class="bi bi-robot me-1"></i>Assistente
@else
<i class="bi bi-gear me-1"></i>Sistema
@endif
<span class="ml-2" style="font-size: 0.7rem;">{{ $message->created_at->format('H:i') }}</span>
</div>
<div style="white-space: pre-wrap;">{{ $message->content }}</div>
</div>
</div>
@empty
<p class="text-center text-muted py-4">Nenhuma mensagem nesta conversa.</p>
@endforelse
</div>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,139 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Conversas')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Chatbot - Conversas
</h1>
<div class="d-flex gap-2">
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-book me-1"></i>Base de Conhecimento
</a>
<a href="{{ route('admin.chatbot.settings') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Configurações
</a>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<!-- Conversations Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Contatos Recebidos ({{ $conversations->total() }} conversas)</h6>
</div>
<div class="card-body">
@if($conversations->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th>Nome</th>
<th>Telefone</th>
<th>Cidade</th>
<th>Status</th>
<th>Avaliação</th>
<th>Mensagens</th>
<th>Data</th>
<th width="140">Ações</th>
</tr>
</thead>
<tbody>
@foreach($conversations as $conv)
<tr class="{{ $conv->status === 'ended' ? '' : 'font-weight-bold' }}">
<td>
<i class="bi bi-person me-1 text-primary"></i>{{ $conv->visitor_name }}
</td>
<td>
@if($conv->visitor_phone)
<a href="tel:{{ $conv->visitor_phone }}">
<i class="bi bi-telephone me-1"></i>{{ $conv->visitor_phone }}
</a>
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($conv->visitor_city)
<i class="bi bi-geo-alt me-1"></i>{{ $conv->visitor_city }}
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($conv->status === 'active')
<span class="badge badge-success">
<i class="bi bi-chat-dots me-1"></i>Ativa
</span>
@else
<span class="badge badge-secondary">
<i class="bi bi-check-circle me-1"></i>Finalizada
</span>
@endif
</td>
<td>
@if($conv->rating)
<span style="color: #f5a623; font-size: 1.1rem; letter-spacing: 2px;">
{{ str_repeat('★', $conv->rating) }}{{ str_repeat('☆', 5 - $conv->rating) }}
</span>
@if($conv->rating_comment)
<br><small class="text-muted" title="{{ $conv->rating_comment }}">
<i class="bi bi-chat-quote me-1"></i>{{ Str::limit($conv->rating_comment, 40) }}
</small>
@endif
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
<span class="badge badge-info">{{ $conv->messages->count() }} msgs</span>
</td>
<td>{{ $conv->created_at->format('d/m/Y H:i') }}</td>
<td>
<a href="{{ route('admin.chatbot.conversation.show', $conv) }}" class="btn btn-sm btn-outline-primary" title="Ver conversa">
<i class="bi bi-eye"></i>
</a>
@if($conv->visitor_phone)
<a href="https://wa.me/55{{ preg_replace('/\D/', '', $conv->visitor_phone) }}" target="_blank" class="btn btn-sm btn-outline-success" title="WhatsApp">
<i class="bi bi-whatsapp"></i>
</a>
@endif
<form action="{{ route('admin.chatbot.conversation.destroy', $conv) }}" method="POST" class="d-inline" onsubmit="return confirm('Tem certeza que deseja excluir esta conversa?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="d-flex justify-content-center">
{{ $conversations->withQueryString()->links() }}
</div>
@else
<div class="text-center py-5">
<i class="bi bi-chat-left-text text-muted" style="font-size: 3rem;"></i>
<h5 class="mt-3 text-muted">Nenhuma conversa registrada</h5>
<p class="text-muted">As conversas aparecerão aqui quando os visitantes iniciarem um chat.</p>
</div>
@endif
</div>
</div>
</div>
@endsection
+97
View File
@@ -0,0 +1,97 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Nova Informação')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Nova Informação para o Chatbot
</h1>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Voltar
</a>
</div>
@if($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Dados da Informação</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.chatbot.store') }}" method="POST">
@csrf
<div class="form-group row">
<label for="title" class="col-sm-2 col-form-label">Título <span class="text-danger">*</span></label>
<div class="col-sm-10">
<input type="text" class="form-control" id="title" name="title"
value="{{ old('title') }}" placeholder="Ex: Horário de funcionamento" required>
<small class="text-muted">Um título descritivo para identificar esta informação.</small>
</div>
</div>
<div class="form-group row">
<label for="category" class="col-sm-2 col-form-label">Categoria <span class="text-danger">*</span></label>
<div class="col-sm-10">
<select class="form-control" id="category" name="category" required>
@foreach($categories as $value => $label)
<option value="{{ $value }}" {{ old('category') == $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
</div>
<div class="form-group row">
<label for="content" class="col-sm-2 col-form-label">Conteúdo <span class="text-danger">*</span></label>
<div class="col-sm-10">
<textarea class="form-control" id="content" name="content" rows="6" required
placeholder="Ex: Funcionamos de segunda a sexta, das 8h às 18h, e aos sábados das 8h às 12h.">{{ old('content') }}</textarea>
<small class="text-muted">Descreva a informação em detalhes. Quanto mais detalhada, melhor será a resposta do assistente.</small>
</div>
</div>
<div class="form-group row">
<label for="restrictions" class="col-sm-2 col-form-label">Restrições</label>
<div class="col-sm-10">
<textarea class="form-control" id="restrictions" name="restrictions" rows="3"
placeholder="Ex: Não informar valores exatos de serviços, apenas faixas de preço.">{{ old('restrictions') }}</textarea>
<small class="text-muted">O que a IA <strong>NÃO</strong> deve fazer ou informar sobre este tópico. Deixe vazio se não houver restrições.</small>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="active" name="active"
{{ old('active', true) ? 'checked' : '' }}>
<label class="custom-control-label" for="active">Informação ativa (visível para o assistente)</label>
</div>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Salvar Informação
</button>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary ml-2">Cancelar</a>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
+95
View File
@@ -0,0 +1,95 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Editar Informação')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Editar Informação do Chatbot
</h1>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Voltar
</a>
</div>
@if($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Editar: {{ $entry->title }}</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.chatbot.update', $entry) }}" method="POST">
@csrf
@method('PUT')
<div class="form-group row">
<label for="title" class="col-sm-2 col-form-label">Título <span class="text-danger">*</span></label>
<div class="col-sm-10">
<input type="text" class="form-control" id="title" name="title"
value="{{ old('title', $entry->title) }}" required>
</div>
</div>
<div class="form-group row">
<label for="category" class="col-sm-2 col-form-label">Categoria <span class="text-danger">*</span></label>
<div class="col-sm-10">
<select class="form-control" id="category" name="category" required>
@foreach($categories as $value => $label)
<option value="{{ $value }}" {{ old('category', $entry->category) == $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
</div>
<div class="form-group row">
<label for="content" class="col-sm-2 col-form-label">Conteúdo <span class="text-danger">*</span></label>
<div class="col-sm-10">
<textarea class="form-control" id="content" name="content" rows="6" required>{{ old('content', $entry->content) }}</textarea>
</div>
</div>
<div class="form-group row">
<label for="restrictions" class="col-sm-2 col-form-label">Restrições</label>
<div class="col-sm-10">
<textarea class="form-control" id="restrictions" name="restrictions" rows="3"
placeholder="Ex: Não informar valores exatos de serviços, apenas faixas de preço.">{{ old('restrictions', $entry->restrictions) }}</textarea>
<small class="text-muted">O que a IA <strong>NÃO</strong> deve fazer ou informar sobre este tópico. Deixe vazio se não houver restrições.</small>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="active" name="active"
{{ old('active', $entry->active) ? 'checked' : '' }}>
<label class="custom-control-label" for="active">Informação ativa (visível para o assistente)</label>
</div>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Atualizar Informação
</button>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary ml-2">Cancelar</a>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
+126
View File
@@ -0,0 +1,126 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Base de Conhecimento')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Chatbot - Base de Conhecimento
</h1>
<div class="d-flex gap-2">
<a href="{{ route('admin.chatbot.settings') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Configurações
</a>
<a href="{{ route('admin.chatbot.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Nova Informação
</a>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<!-- Info Card -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Informações do Chatbot</h6>
</div>
<div class="card-body">
<p class="text-muted mb-0">
<i class="bi bi-info-circle me-1"></i>
Adicione informações sobre a empresa, produtos e serviços para que o assistente virtual possa responder aos visitantes.
Quanto mais detalhada for a base de conhecimento, melhores serão as respostas.
</p>
</div>
</div>
<!-- Knowledge Base Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Base de Conhecimento ({{ $knowledgeEntries->total() }} entradas)</h6>
</div>
<div class="card-body">
@if($knowledgeEntries->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th>Título</th>
<th>Categoria</th>
<th>Conteúdo</th>
<th>Restrições</th>
<th>Status</th>
<th width="120">Ações</th>
</tr>
</thead>
<tbody>
@foreach($knowledgeEntries as $entry)
<tr>
<td class="font-weight-bold">{{ $entry->title }}</td>
<td>
<span class="badge badge-info">
{{ $categories[$entry->category] ?? $entry->category }}
</span>
</td>
<td class="text-truncate" style="max-width: 250px;">
{{ Str::limit($entry->content, 80) }}
</td>
<td class="text-truncate" style="max-width: 200px;">
@if($entry->restrictions)
<span class="text-warning" title="{{ $entry->restrictions }}">
<i class="bi bi-exclamation-triangle me-1"></i>{{ Str::limit($entry->restrictions, 50) }}
</span>
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($entry->active)
<span class="badge badge-success">Ativo</span>
@else
<span class="badge badge-secondary">Inativo</span>
@endif
</td>
<td>
<a href="{{ route('admin.chatbot.edit', $entry) }}" class="btn btn-sm btn-outline-primary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('admin.chatbot.destroy', $entry) }}" method="POST" class="d-inline" onsubmit="return confirm('Tem certeza que deseja excluir esta informação?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="d-flex justify-content-center">
{{ $knowledgeEntries->withQueryString()->links() }}
</div>
@else
<div class="text-center py-5">
<i class="bi bi-robot text-muted" style="font-size: 3rem;"></i>
<h5 class="mt-3 text-muted">Nenhuma informação cadastrada</h5>
<p class="text-muted">Adicione informações sobre a empresa para o assistente virtual.</p>
<a href="{{ route('admin.chatbot.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Adicionar Primeira Informação
</a>
</div>
@endif
</div>
</div>
</div>
@endsection
@@ -0,0 +1,119 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Nova Regra')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-shield-lock me-2"></i>Nova Regra para a IA
</h1>
<a href="{{ route('admin.chatbot.rules.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Voltar
</a>
</div>
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong><i class="bi bi-exclamation-triangle me-1"></i>Erro(s):</strong>
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="Fechar">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Dados da Regra</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.chatbot.rules.store') }}" method="POST">
@csrf
<div class="form-group row">
<label for="title" class="col-sm-3 col-form-label">Título <span class="text-danger">*</span></label>
<div class="col-sm-9">
<input type="text" class="form-control" id="title" name="title"
value="{{ old('title') }}" required maxlength="255"
placeholder="Ex: Sempre oferecer WhatsApp para agendamentos">
<small class="text-muted">Nome curto para identificar a regra na listagem.</small>
</div>
</div>
<div class="form-group row">
<label for="content" class="col-sm-3 col-form-label">Conteúdo da Regra <span class="text-danger">*</span></label>
<div class="col-sm-9">
<textarea class="form-control" id="content" name="content" rows="4" required maxlength="2000"
placeholder="Ex: Sempre que o visitante demonstrar interesse em agendar um serviço, ofereça o WhatsApp (48) 99999-9999 como canal preferencial de agendamento.">{{ old('content') }}</textarea>
<small class="text-muted">A instrução completa que será enviada à IA. Máximo de 2000 caracteres.</small>
</div>
</div>
<div class="form-group row">
<label for="order" class="col-sm-3 col-form-label">Ordem</label>
<div class="col-sm-9">
<input type="number" class="form-control" id="order" name="order"
value="{{ old('order', 0) }}" min="0" max="9999">
<small class="text-muted">Define a prioridade da regra (menor número = maior prioridade). Padrão: 0.</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Status</label>
<div class="col-sm-9">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="active" name="active"
{{ old('active', true) ? 'checked' : '' }}>
<label class="custom-control-label" for="active">
<strong>Regra Ativa</strong> — Quando marcada, a regra será seguida pela IA nas conversas.
</label>
</div>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-sm-9 offset-sm-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Salvar Regra
</button>
<a href="{{ route('admin.chatbot.rules.index') }}" class="btn btn-secondary ml-2">Cancelar</a>
</div>
</div>
</form>
</div>
</div>
<!-- Examples Card -->
<div class="card border-info mb-4">
<div class="card-header bg-info text-white py-3">
<h6 class="m-0 font-weight-bold"><i class="bi bi-lightbulb me-1"></i>Exemplos de Regras Úteis</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p class="small mb-1"><strong>🎯 Direcionar para contato humano:</strong></p>
<p class="small text-muted">"Sempre que o visitante tiver uma dúvida complexa ou reclamação, sugira entrar em contato pelo telefone (XX) XXXX-XXXX."</p>
</div>
<div class="col-md-6">
<p class="small mb-1"><strong>💬 Tom de voz:</strong></p>
<p class="small text-muted">"Use sempre linguagem formal e profissional. Trate o visitante por 'você' mas evite gírias."</p>
</div>
<div class="col-md-6">
<p class="small mb-1"><strong>🚫 Proibir descontos:</strong></p>
<p class="small text-muted">"NUNCA ofereça descontos, promova valores ou negocie preços. Indique que valores são praticados apenas pela equipe comercial."</p>
</div>
<div class="col-md-6">
<p class="small mb-1"><strong>📍 Atendimento regional:</strong></p>
<p class="small text-muted">"Atendemos apenas as cidades de Florianópolis, São José e Biguaçu. Para outras regiões, informe que não há cobertura."</p>
</div>
</div>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,91 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Editar Regra')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-shield-lock me-2"></i>Editar Regra da IA
</h1>
<a href="{{ route('admin.chatbot.rules.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Voltar
</a>
</div>
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong><i class="bi bi-exclamation-triangle me-1"></i>Erro(s):</strong>
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="Fechar">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Dados da Regra</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.chatbot.rules.update', $rule) }}" method="POST">
@csrf
@method('PUT')
<div class="form-group row">
<label for="title" class="col-sm-3 col-form-label">Título <span class="text-danger">*</span></label>
<div class="col-sm-9">
<input type="text" class="form-control" id="title" name="title"
value="{{ old('title', $rule->title) }}" required maxlength="255">
<small class="text-muted">Nome curto para identificar a regra na listagem.</small>
</div>
</div>
<div class="form-group row">
<label for="content" class="col-sm-3 col-form-label">Conteúdo da Regra <span class="text-danger">*</span></label>
<div class="col-sm-9">
<textarea class="form-control" id="content" name="content" rows="4" required maxlength="2000">{{ old('content', $rule->content) }}</textarea>
<small class="text-muted">A instrução completa que será enviada à IA. Máximo de 2000 caracteres.</small>
</div>
</div>
<div class="form-group row">
<label for="order" class="col-sm-3 col-form-label">Ordem</label>
<div class="col-sm-9">
<input type="number" class="form-control" id="order" name="order"
value="{{ old('order', $rule->order) }}" min="0" max="9999">
<small class="text-muted">Define a prioridade da regra (menor número = maior prioridade).</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Status</label>
<div class="col-sm-9">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="active" name="active"
{{ old('active', $rule->active) ? 'checked' : '' }}>
<label class="custom-control-label" for="active">
<strong>Regra Ativa</strong> — Quando marcada, a regra será seguida pela IA nas conversas.
</label>
</div>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-sm-9 offset-sm-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Salvar Alterações
</button>
<a href="{{ route('admin.chatbot.rules.index') }}" class="btn btn-secondary ml-2">Cancelar</a>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,110 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Regras da IA')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-shield-lock me-2"></i>Chatbot - Regras da IA
</h1>
<a href="{{ route('admin.chatbot.rules.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Nova Regra
</a>
</div>
<!-- Info Card -->
<div class="card border-info mb-4">
<div class="card-body">
<h6 class="card-title mb-2"><i class="bi bi-info-circle me-1 text-info"></i>Como funcionam as Regras</h6>
<p class="small text-muted mb-0">
As regras são instruções obrigatórias que a IA <strong>deve seguir</strong> em todas as respostas.
Elas são enviadas automaticamente no início do prompt do sistema, antes das informações da base de conhecimento.
Use para definir tom de voz, proibições específicas, fluxos obrigatórios ou qualquer comportamento desejado.
Regras desativadas são ignoradas. Use o campo <strong>Ordem</strong> para definir a prioridade (menor = mais importante).
</p>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-1"></i>{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Fechar">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<!-- Rules List -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Regras Configuradas</h6>
</div>
<div class="card-body p-0">
@if($rules->isEmpty())
<div class="text-center py-5">
<i class="bi bi-shield-lock" style="font-size:3rem;color:#dddfeb;"></i>
<p class="mt-3 text-muted">Nenhuma regra configurada ainda.</p>
<p class="text-muted small">Adicione regras para definir como a IA deve se comportar no atendimento.</p>
<a href="{{ route('admin.chatbot.rules.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Adicionar Primeira Regra
</a>
</div>
@else
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="thead-light">
<tr>
<th width="60">Ordem</th>
<th>Título</th>
<th>Conteúdo da Regra</th>
<th width="100">Status</th>
<th width="140">Ações</th>
</tr>
</thead>
<tbody>
@foreach($rules as $rule)
<tr>
<td>
<span class="badge badge-secondary">{{ $rule->order }}</span>
</td>
<td>
<strong>{{ $rule->title }}</strong>
</td>
<td>
<span class="text-muted">{{ \Illuminate\Support\Str::limit($rule->content, 150) }}</span>
</td>
<td>
@if($rule->active)
<span class="badge badge-success"><i class="bi bi-check-circle"></i> Ativa</span>
@else
<span class="badge badge-secondary"><i class="bi bi-pause-circle"></i> Inativa</span>
@endif
</td>
<td>
<a href="{{ route('admin.chatbot.rules.edit', $rule) }}" class="btn btn-sm btn-outline-primary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('admin.chatbot.rules.destroy', $rule) }}" method="POST" class="d-inline" onsubmit="return confirm('Tem certeza que deseja excluir esta regra?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="p-3">
{{ $rules->withQueryString()->links() }}
</div>
@endif
</div>
</div>
</div>
@endsection
@@ -0,0 +1,296 @@
@extends('layouts.admin')
@section('title', 'Chatbot - Configurações')
@section('content')
<div class="container-fluid px-4">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 text-gray-800">
<i class="bi bi-robot me-2"></i>Chatbot - Configurações
</h1>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Base de Conhecimento
</a>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@php
$totalTokens = (int) \App\Models\Setting::get('chatbot_total_tokens_used', 0);
$totalMessages = \App\Modules\Chatbot\Models\ChatbotMessage::where('role', 'assistant')->count();
$totalConversations = \App\Modules\Chatbot\Models\ChatbotConversation::whereNotNull('visitor_name')->count();
@endphp
<!-- Token Usage Stats -->
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Total de Tokens Consumidos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($totalTokens, 0, ',', '.') }}</div>
</div>
<div class="col-auto">
<i class="bi bi-cpu text-primary" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Respostas da IA</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($totalMessages, 0, ',', '.') }}</div>
</div>
<div class="col-auto">
<i class="bi bi-robot text-success" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Conversas com Visitantes</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($totalConversations, 0, ',', '.') }}</div>
</div>
<div class="col-auto">
<i class="bi bi-chat-dots text-info" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<form action="{{ route('admin.chatbot.settings.update') }}" method="POST">
@csrf
@method('POST')
<!-- General Settings -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-toggle-on me-1"></i>Ativação
</h6>
</div>
<div class="card-body">
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="chatbot_enabled" name="chatbot_enabled"
{{ ($settings['chatbot_enabled'] ?? 'off') === 'on' ? 'checked' : '' }}>
<label class="custom-control-label" for="chatbot_enabled">
<strong>Ativar Chatbot</strong> — Exibe o widget de chat no site público
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Provider Selection -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-cpu me-1"></i>Provedor de IA
</h6>
</div>
<div class="card-body">
<div class="form-group row">
<label for="chatbot_provider" class="col-sm-3 col-form-label">Provedor <span class="text-danger">*</span></label>
<div class="col-sm-9">
<select class="form-control" id="chatbot_provider" name="chatbot_provider" onchange="toggleProviderFields()">
<option value="openrouter" {{ ($settings['chatbot_provider'] ?? 'openrouter') === 'openrouter' ? 'selected' : '' }}>OpenRouter.ai (nuvem)</option>
<option value="llamacpp" {{ ($settings['chatbot_provider'] ?? 'openrouter') === 'llamacpp' ? 'selected' : '' }}>llama.cpp (local)</option>
</select>
<small class="text-muted">Escolha entre usar a API do OpenRouter.ai na nuvem ou um servidor llama.cpp local.</small>
</div>
</div>
</div>
</div>
<!-- OpenRouter Settings -->
<div class="card shadow mb-4" id="openrouter-settings">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-key me-1"></i>API OpenRouter
</h6>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
O Chatbot pode utilizar a API do <a href="https://openrouter.ai" target="_blank">OpenRouter.ai</a> para gerar respostas inteligentes.
Crie uma conta gratuita em openrouter.ai, obtenha sua chave de API e configure abaixo.
</div>
<div class="form-group row">
<label for="chatbot_openrouter_api_key" class="col-sm-3 col-form-label">Chave da API <span class="text-danger">*</span></label>
<div class="col-sm-9">
<input type="password" class="form-control" id="chatbot_openrouter_api_key"
name="chatbot_openrouter_api_key"
value="{{ $settings['chatbot_openrouter_api_key'] ?? '' }}"
placeholder="sk-or-v1-...">
<small class="text-muted">Sua chave de API do OpenRouter.ai. Mantenha este campo seguro.</small>
</div>
</div>
<div class="form-group row">
<label for="chatbot_openrouter_model" class="col-sm-3 col-form-label">Modelo de IA</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="chatbot_openrouter_model"
name="chatbot_openrouter_model"
value="{{ $settings['chatbot_openrouter_model'] ?? 'meta-llama/llama-3.1-8b-instruct' }}"
placeholder="meta-llama/llama-3.1-8b-instruct">
<small class="text-muted">Digite o ID do modelo (ex: meta-llama/llama-3.2-1b-instruct). Verifique os modelos disponíveis no <a href="https://openrouter.ai/models" target="_blank">OpenRouter.ai</a>.</small>
</div>
</div>
</div>
</div>
<!-- llama.cpp Settings -->
<div class="card shadow mb-4" id="llamacpp-settings">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-server me-1"></i>llama.cpp (Servidor Local)
</h6>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
Utilize um servidor <a href="https://github.com/ggerganov/llama.cpp" target="_blank">llama.cpp</a> rodando localmente para processar as respostas.
O servidor deve estar acessível com API compatível com OpenAI (<code>/v1/chat/completions</code>).
</div>
<div class="form-group row">
<label for="chatbot_llamacpp_base_url" class="col-sm-3 col-form-label">URL Base <span class="text-danger">*</span></label>
<div class="col-sm-9">
<input type="text" class="form-control" id="chatbot_llamacpp_base_url"
name="chatbot_llamacpp_base_url"
value="{{ $settings['chatbot_llamacpp_base_url'] ?? 'http://172.17.0.1:8080' }}"
placeholder="http://172.17.0.1:8080">
<small class="text-muted">URL do servidor llama.cpp (ex: http://172.17.0.1:8080). O servidor deve expor a rota <code>/v1/chat/completions</code>.</small>
</div>
</div>
<div class="form-group row">
<label for="chatbot_llamacpp_model" class="col-sm-3 col-form-label">Nome do Modelo</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="chatbot_llamacpp_model"
name="chatbot_llamacpp_model"
value="{{ $settings['chatbot_llamacpp_model'] ?? '' }}"
placeholder="opcional">
<small class="text-muted">Algumas configurações do llama.cpp exigem o nome do modelo. Deixe em branco se não for necessário.</small>
</div>
</div>
</div>
</div>
<!-- Parameters -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-sliders me-1"></i>Parâmetros da IA
</h6>
</div>
<div class="card-body">
<div class="form-group row">
<label for="chatbot_max_tokens" class="col-sm-3 col-form-label">Máx. Tokens de Resposta</label>
<div class="col-sm-9">
<input type="number" class="form-control" id="chatbot_max_tokens"
name="chatbot_max_tokens" min="50" max="2000"
value="{{ $settings['chatbot_max_tokens'] ?? '500' }}">
<small class="text-muted">Controla o tamanho máximo da resposta (50-2000). Recomendado: 500.</small>
</div>
</div>
<div class="form-group row">
<label for="chatbot_temperature" class="col-sm-3 col-form-label">Temperatura</label>
<div class="col-sm-9">
<input type="number" class="form-control" id="chatbot_temperature"
name="chatbot_temperature" min="0" max="2" step="0.1"
value="{{ $settings['chatbot_temperature'] ?? '0.7' }}">
<small class="text-muted">Controla a criatividade das respostas (0.0 = preciso, 2.0 = criativo). Recomendado: 0.7.</small>
</div>
</div>
</div>
</div>
<!-- Appearance Settings -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="bi bi-chat-dots me-1"></i>Aparência e Mensagens
</h6>
</div>
<div class="card-body">
<div class="form-group row">
<label for="chatbot_welcome_message" class="col-sm-3 col-form-label">Mensagem de Boas-vindas</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="chatbot_welcome_message"
name="chatbot_welcome_message"
value="{{ $settings['chatbot_welcome_message'] ?? 'Olá! Como posso ajudar você hoje?' }}"
maxlength="500">
<small class="text-muted">Primeira mensagem exibida quando o visitante abre o chat.</small>
</div>
</div>
<div class="form-group row">
<label for="chatbot_custom_instructions" class="col-sm-3 col-form-label">Instruções Personalizadas</label>
<div class="col-sm-9">
<textarea class="form-control" id="chatbot_custom_instructions"
name="chatbot_custom_instructions" rows="4"
placeholder="Ex: Sempre ofereça o WhatsApp como canal de contato para agendamentos.">{{ $settings['chatbot_custom_instructions'] ?? '' }}</textarea>
<small class="text-muted">Instruções extras para o comportamento do assistente. Estas são adicionadas ao prompt do sistema.</small>
</div>
</div>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Salvar Configurações
</button>
<a href="{{ route('admin.chatbot.index') }}" class="btn btn-secondary ml-2">Cancelar</a>
</div>
</form>
</div>
<script>
function toggleProviderFields() {
const provider = document.getElementById('chatbot_provider').value;
document.getElementById('openrouter-settings').style.display = provider === 'openrouter' ? 'block' : 'none';
document.getElementById('llamacpp-settings').style.display = provider === 'llamacpp' ? 'block' : 'none';
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
toggleProviderFields();
});
</script>
@endsection
@@ -0,0 +1,948 @@
@php
$chatbotEnabled = \App\Models\Setting::get('chatbot_enabled', 'off') === 'on';
@endphp
@if($chatbotEnabled && module_enabled('chatbot'))
<!-- Chatbot Widget -->
<style>
#chatbot-toggle {
position: fixed;
bottom: 20px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #4e73df, #2e59d9);
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 15px rgba(78, 115, 223, 0.4);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
font-size: 1.4rem;
}
#chatbot-toggle:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(78, 115, 223, 0.6);
}
#chatbot-window {
position: fixed;
bottom: 90px;
right: 30px;
width: 370px;
max-height: 480px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
z-index: 998;
display: none;
flex-direction: column;
overflow: hidden;
background: #fff;
animation: chatSlideUp 0.3s ease;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
#chatbot-window.open {
display: flex;
}
.chatbot-header {
background: linear-gradient(135deg, #4e73df, #2e59d9);
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.chatbot-header h6 {
margin: 0;
font-weight: 700;
font-size: 1rem;
}
.chatbot-header small {
opacity: 0.8;
font-size: 0.75rem;
}
.chatbot-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chatbot-close {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer;
padding: 0;
opacity: 0.8;
transition: opacity 0.2s;
}
.chatbot-close:hover {
opacity: 1;
}
.chatbot-end-btn {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
color: white;
font-size: 0.75rem;
cursor: pointer;
padding: 3px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.2s;
white-space: nowrap;
}
.chatbot-end-btn:hover {
background: rgba(255,255,255,0.35);
}
/* Pre-chat form */
.chatbot-prechat {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.chatbot-prechat p {
color: #666;
font-size: 0.875rem;
margin-bottom: 16px;
}
.chatbot-prechat .form-group {
margin-bottom: 12px;
}
.chatbot-prechat label {
font-size: 0.8rem;
font-weight: 600;
color: #444;
margin-bottom: 4px;
display: block;
}
.chatbot-prechat input {
width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px 12px;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.chatbot-prechat input:focus {
border-color: #4e73df;
}
.chatbot-prechat .btn-start {
width: 100%;
background: linear-gradient(135deg, #4e73df, #2e59d9);
color: white;
border: none;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.chatbot-prechat .btn-start:hover {
opacity: 0.9;
}
.chatbot-prechat .btn-start:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chatbot-prechat .error-msg {
color: #e74c3c;
font-size: 0.8rem;
margin-top: 8px;
display: none;
}
/* Chat area */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
max-height: 360px;
min-height: 200px;
}
.chatbot-message {
margin-bottom: 12px;
display: flex;
}
.chatbot-message.user {
justify-content: flex-end;
}
.chatbot-message .bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 16px;
font-size: 0.875rem;
line-height: 1.5;
word-wrap: break-word;
}
.chatbot-message.assistant .bubble {
background: #f0f2f5;
color: #333;
border-bottom-left-radius: 4px;
}
.chatbot-message.user .bubble {
background: #4e73df;
color: white;
border-bottom-right-radius: 4px;
}
.chatbot-typing {
display: none;
margin-bottom: 12px;
padding: 0 16px;
}
.chatbot-typing .bubble {
background: #f0f2f5;
padding: 12px 18px;
border-radius: 16px;
border-bottom-left-radius: 4px;
display: flex;
gap: 4px;
}
.chatbot-typing .dot {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: typingDot 1.4s infinite;
}
.chatbot-typing .dot:nth-child(2) { animation-delay: 0.2s; }
.chatbot-typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
.chatbot-input {
display: flex;
padding: 12px 16px;
border-top: 1px solid #e0e0e0;
background: #fafafa;
flex-shrink: 0;
}
.chatbot-input input {
flex: 1;
border: 1px solid #ddd;
border-radius: 24px;
padding: 8px 16px;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s;
}
.chatbot-input input:focus {
border-color: #4e73df;
}
.chatbot-input button {
background: #4e73df;
color: white;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
margin-left: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.chatbot-input button:hover {
background: #2e59d9;
}
.chatbot-input button:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Rating form */
.chatbot-rating {
padding: 20px;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.chatbot-rating h6 {
color: #333;
font-weight: 700;
margin-bottom: 8px;
font-size: 1rem;
}
.chatbot-rating p {
color: #666;
font-size: 0.85rem;
margin-bottom: 16px;
}
.chatbot-rating .stars {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.chatbot-rating .stars .star {
font-size: 2rem;
cursor: pointer;
color: #ddd;
transition: color 0.2s, transform 0.2s;
background: none;
border: none;
padding: 0;
line-height: 1;
}
.chatbot-rating .stars .star:hover,
.chatbot-rating .stars .star.active {
color: #f5a623;
transform: scale(1.15);
}
.chatbot-rating textarea {
width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px 12px;
font-size: 0.85rem;
outline: none;
resize: none;
height: 60px;
margin-bottom: 12px;
box-sizing: border-box;
font-family: inherit;
}
.chatbot-rating textarea:focus {
border-color: #4e73df;
}
.chatbot-rating .btn-submit-rating {
width: 100%;
background: linear-gradient(135deg, #4e73df, #2e59d9);
color: white;
border: none;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.chatbot-rating .btn-submit-rating:hover {
opacity: 0.9;
}
.chatbot-rating .btn-submit-rating:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chatbot-rating .btn-skip {
background: none;
border: none;
color: #999;
font-size: 0.8rem;
cursor: pointer;
margin-top: 8px;
text-decoration: underline;
}
.chatbot-rating .btn-skip:hover {
color: #666;
}
.chatbot-rating .error-msg {
color: #e74c3c;
font-size: 0.8rem;
margin-bottom: 8px;
display: none;
}
/* Thank you */
.chatbot-thankyou {
padding: 30px 20px;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.chatbot-thankyou .icon {
font-size: 3rem;
color: #1cc88a;
margin-bottom: 12px;
}
.chatbot-thankyou h6 {
color: #333;
font-weight: 700;
margin-bottom: 8px;
}
.chatbot-thankyou p {
color: #666;
font-size: 0.85rem;
}
.chatbot-thankyou .btn-new-chat {
margin-top: 16px;
background: linear-gradient(135deg, #4e73df, #2e59d9);
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.chatbot-disclaimer {
padding: 6px 16px;
background: #fff8e1;
border-top: 1px solid #ffe082;
font-size: 0.7rem;
color: #8d6e00;
text-align: center;
flex-shrink: 0;
}
@media (max-width: 480px) {
#chatbot-toggle {
bottom: 15px;
right: 20px;
width: 50px;
height: 50px;
font-size: 1.2rem;
}
#chatbot-window {
width: calc(100vw - 20px);
right: 10px;
bottom: 160px;
}
}
</style>
<button id="chatbot-toggle" title="Chat com assistente virtual">
<i class="bi bi-chat-dots-fill"></i>
</button>
<div id="chatbot-window">
<div class="chatbot-header">
<div>
<h6><i class="bi bi-robot me-1"></i>Assistente Virtual</h6>
<small>Online • Respostas instantâneas</small>
</div>
<div class="chatbot-header-actions">
<button class="chatbot-end-btn" id="chatbot-end-btn" title="Finalizar conversa" style="display:none;">
<i class="bi bi-x-circle"></i> Finalizar
</button>
<button class="chatbot-close" id="chatbot-close-btn">
<i class="bi bi-chevron-down"></i>
</button>
</div>
</div>
<!-- Pre-chat Registration Form -->
<div class="chatbot-prechat" id="chatbot-prechat">
<p>Para um atendimento personalizado, por favor preencha seus dados:</p>
<div class="form-group">
<label for="chatbot-visitor-name">Nome <span style="color:#e74c3c">*</span></label>
<input type="text" id="chatbot-visitor-name" placeholder="Seu nome completo" required>
</div>
<div class="form-group">
<label for="chatbot-visitor-phone">Telefone <span style="color:#e74c3c">*</span></label>
<input type="tel" id="chatbot-visitor-phone" placeholder="(00) 00000-0000">
</div>
<div class="form-group">
<label for="chatbot-visitor-city">Cidade <span style="color:#e74c3c">*</span></label>
<input type="text" id="chatbot-visitor-city" placeholder="Sua cidade">
</div>
<div class="error-msg" id="chatbot-register-error"></div>
<button class="btn-start" id="chatbot-start-btn">
<i class="bi bi-chat-dots me-1"></i>Iniciar Conversa
</button>
</div>
<!-- Chat Area (hidden until registration) -->
<div id="chatbot-chat-area" style="display:none; flex-direction:column; flex:1; overflow:hidden;">
<div class="chatbot-messages" id="chatbot-messages">
<div class="chatbot-message assistant">
<div class="bubble" id="chatbot-welcome">Olá! Como posso ajudar você hoje?</div>
</div>
</div>
<div class="chatbot-typing" id="chatbot-typing">
<div class="bubble">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<div class="chatbot-input" id="chatbot-input-area">
<input type="text" id="chatbot-input" placeholder="Digite sua mensagem..." maxlength="1000" autocomplete="off">
<button id="chatbot-send-btn" title="Enviar">
<i class="bi bi-send-fill"></i>
</button>
</div>
<div class="chatbot-disclaimer">
<i class="bi bi-shield-check me-1"></i>Não compartilhe dados sensíveis (senha, cartão, CPF, outros) neste chat.
</div>
</div>
<!-- Rating Form (hidden until conversation ends) -->
<div class="chatbot-rating" id="chatbot-rating" style="display:none;">
<h6><i class="bi bi-star me-1"></i>Avalie o Atendimento</h6>
<p>Como foi sua experiência com nosso assistente?</p>
<div class="stars" id="chatbot-stars">
<button class="star" data-value="1">★</button>
<button class="star" data-value="2">★</button>
<button class="star" data-value="3">★</button>
<button class="star" data-value="4">★</button>
<button class="star" data-value="5">★</button>
</div>
<textarea id="chatbot-rating-comment" placeholder="Deixe um comentário (opcional)..."></textarea>
<div class="error-msg" id="chatbot-rating-error"></div>
<button class="btn-submit-rating" id="chatbot-submit-rating">
<i class="bi bi-check-circle me-1"></i>Enviar Avaliação
</button>
<button class="btn-skip" id="chatbot-skip-rating">Pular avaliação</button>
</div>
<!-- Thank You (hidden until rating submitted) -->
<div class="chatbot-thankyou" id="chatbot-thankyou" style="display:none;">
<div class="icon"><i class="bi bi-check-circle-fill"></i></div>
<h6>Obrigado pelo feedback!</h6>
<p>Sua opinião é muito importante para melhorarmos nosso atendimento.</p>
<button class="btn-new-chat" id="chatbot-new-chat-btn">
<i class="bi bi-chat-dots me-1"></i>Nova Conversa
</button>
</div>
</div>
<script>
(function() {
const toggle = document.getElementById('chatbot-toggle');
const window_ = document.getElementById('chatbot-window');
const closeBtn = document.getElementById('chatbot-close-btn');
const endBtn = document.getElementById('chatbot-end-btn');
const prechat = document.getElementById('chatbot-prechat');
const chatArea = document.getElementById('chatbot-chat-area');
const messagesDiv = document.getElementById('chatbot-messages');
const typingIndicator = document.getElementById('chatbot-typing');
const inputEl = document.getElementById('chatbot-input');
const inputArea = document.getElementById('chatbot-input-area');
const sendBtn = document.getElementById('chatbot-send-btn');
const welcomeEl = document.getElementById('chatbot-welcome');
const startBtn = document.getElementById('chatbot-start-btn');
const errorMsg = document.getElementById('chatbot-register-error');
const nameInput = document.getElementById('chatbot-visitor-name');
const phoneInput = document.getElementById('chatbot-visitor-phone');
const cityInput = document.getElementById('chatbot-visitor-city');
const ratingDiv = document.getElementById('chatbot-rating');
const thankyouDiv = document.getElementById('chatbot-thankyou');
const starsContainer = document.getElementById('chatbot-stars');
const ratingComment = document.getElementById('chatbot-rating-comment');
const submitRatingBtn = document.getElementById('chatbot-submit-rating');
const skipRatingBtn = document.getElementById('chatbot-skip-rating');
const ratingError = document.getElementById('chatbot-rating-error');
const newChatBtn = document.getElementById('chatbot-new-chat-btn');
// Generate or retrieve session ID
let sessionId = localStorage.getItem('chatbot_session_id');
if (!sessionId) {
sessionId = 'chat_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('chatbot_session_id', sessionId);
}
// Check if visitor already registered
let visitorRegistered = localStorage.getItem('chatbot_visitor_registered') === 'true';
let conversationEnded = localStorage.getItem('chatbot_conversation_ended') === 'true';
let selectedRating = 0;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
// Phone mask
phoneInput.addEventListener('input', function(e) {
let v = e.target.value.replace(/\D/g, '');
if (v.length > 11) v = v.slice(0, 11);
if (v.length > 6) {
v = '(' + v.slice(0,2) + ') ' + v.slice(2,7) + '-' + v.slice(7);
} else if (v.length > 2) {
v = '(' + v.slice(0,2) + ') ' + v.slice(2);
} else if (v.length > 0) {
v = '(' + v;
}
e.target.value = v;
});
// Load welcome message
fetch('/chatbot/status')
.then(r => r.json())
.then(data => {
if (data.welcome_message && welcomeEl) {
welcomeEl.textContent = data.welcome_message;
}
})
.catch(() => {});
function toggleChat() {
window_.classList.toggle('open');
if (window_.classList.contains('open')) {
if (visitorRegistered && !conversationEnded) {
inputEl.focus();
} else if (!visitorRegistered) {
nameInput.focus();
}
}
}
toggle.addEventListener('click', toggleChat);
closeBtn.addEventListener('click', function() {
window_.classList.remove('open');
});
// Show appropriate view based on state
function showChatView() {
prechat.style.display = 'none';
chatArea.style.display = 'flex';
ratingDiv.style.display = 'none';
thankyouDiv.style.display = 'none';
endBtn.style.display = 'flex';
}
function showRatingView() {
prechat.style.display = 'none';
chatArea.style.display = 'none';
ratingDiv.style.display = 'flex';
thankyouDiv.style.display = 'none';
endBtn.style.display = 'none';
}
function showThankyouView() {
prechat.style.display = 'none';
chatArea.style.display = 'none';
ratingDiv.style.display = 'none';
thankyouDiv.style.display = 'flex';
endBtn.style.display = 'none';
}
// If already registered, show chat area directly
if (visitorRegistered && !conversationEnded) {
showChatView();
} else if (conversationEnded) {
// If conversation was ended, start fresh
resetChat();
}
function resetChat() {
// Generate new session
sessionId = 'chat_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('chatbot_session_id', sessionId);
localStorage.removeItem('chatbot_visitor_registered');
localStorage.removeItem('chatbot_conversation_ended');
visitorRegistered = false;
conversationEnded = false;
selectedRating = 0;
// Clear messages
messagesDiv.innerHTML = '<div class="chatbot-message assistant"><div class="bubble" id="chatbot-welcome">Olá! Como posso ajudar você hoje?</div></div>';
// Show pre-chat form
prechat.style.display = 'block';
chatArea.style.display = 'none';
ratingDiv.style.display = 'none';
thankyouDiv.style.display = 'none';
endBtn.style.display = 'none';
// Clear form fields
nameInput.value = '';
phoneInput.value = '';
cityInput.value = '';
ratingComment.value = '';
// Reset stars
document.querySelectorAll('#chatbot-stars .star').forEach(function(s) {
s.classList.remove('active');
});
// Reload welcome message
fetch('/chatbot/status')
.then(r => r.json())
.then(data => {
const welEl = document.getElementById('chatbot-welcome');
if (data.welcome_message && welEl) {
welEl.textContent = data.welcome_message;
}
})
.catch(() => {});
}
// Register visitor
startBtn.addEventListener('click', function() {
const name = nameInput.value.trim();
const phone = phoneInput.value.trim();
const city = cityInput.value.trim();
errorMsg.style.display = 'none';
if (!name || !phone || !city) {
errorMsg.textContent = 'Por favor, preencha todos os campos obrigatórios.';
errorMsg.style.display = 'block';
return;
}
startBtn.disabled = true;
startBtn.textContent = 'Iniciando...';
fetch('/chatbot/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
session_id: sessionId,
name: name,
phone: phone,
city: city
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
localStorage.setItem('chatbot_visitor_registered', 'true');
visitorRegistered = true;
// Personalize welcome message
const firstName = name.split(' ')[0];
const welEl = document.getElementById('chatbot-welcome');
if (welEl) {
welEl.textContent = welEl.textContent.replace(
'Olá!',
'Olá, ' + firstName + '!'
);
}
// Switch to chat area
showChatView();
inputEl.focus();
} else {
errorMsg.textContent = data.message || 'Erro ao registrar. Tente novamente.';
errorMsg.style.display = 'block';
}
})
.catch(() => {
errorMsg.textContent = 'Erro de conexão. Tente novamente.';
errorMsg.style.display = 'block';
})
.finally(() => {
startBtn.disabled = false;
startBtn.innerHTML = '<i class="bi bi-chat-dots me-1"></i>Iniciar Conversa';
});
});
// Allow Enter key on pre-chat form
[nameInput, phoneInput, cityInput].forEach(function(el) {
el.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
startBtn.click();
}
});
});
function addMessage(role, content) {
const div = document.createElement('div');
div.className = 'chatbot-message ' + role;
div.innerHTML = '<div class="bubble">' + escapeHtml(content) + '</div>';
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showTyping() {
typingIndicator.style.display = 'block';
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function hideTyping() {
typingIndicator.style.display = 'none';
}
function sendMessage() {
const message = inputEl.value.trim();
if (!message) return;
addMessage('user', message);
inputEl.value = '';
sendBtn.disabled = true;
showTyping();
fetch('/chatbot/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
message: message,
session_id: sessionId
})
})
.then(r => r.json())
.then(data => {
hideTyping();
if (data.success) {
addMessage('assistant', data.message);
} else {
if (data.conversation_ended) {
addMessage('assistant', 'Esta conversa foi finalizada.');
endChatLocally();
} else {
addMessage('assistant', data.message || 'Desculpe, ocorreu um erro. Tente novamente.');
}
}
})
.catch(() => {
hideTyping();
addMessage('assistant', 'Desculpe, ocorreu um erro de conexão. Tente novamente.');
})
.finally(() => {
sendBtn.disabled = false;
inputEl.focus();
});
}
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
}
});
// End conversation
endBtn.addEventListener('click', function() {
if (!confirm('Deseja finalizar esta conversa?')) return;
fetch('/chatbot/end', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
session_id: sessionId
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
endChatLocally();
}
})
.catch(() => {
// End locally even if API fails
endChatLocally();
});
});
function endChatLocally() {
conversationEnded = true;
localStorage.setItem('chatbot_conversation_ended', 'true');
showRatingView();
}
// Star rating interaction
const starBtns = document.querySelectorAll('#chatbot-stars .star');
starBtns.forEach(function(star) {
star.addEventListener('click', function() {
selectedRating = parseInt(this.getAttribute('data-value'));
starBtns.forEach(function(s) {
const val = parseInt(s.getAttribute('data-value'));
if (val <= selectedRating) {
s.classList.add('active');
} else {
s.classList.remove('active');
}
});
});
star.addEventListener('mouseenter', function() {
const hoverVal = parseInt(this.getAttribute('data-value'));
starBtns.forEach(function(s) {
const val = parseInt(s.getAttribute('data-value'));
if (val <= hoverVal) {
s.style.color = '#f5a623';
} else {
s.style.color = '#ddd';
}
});
});
star.addEventListener('mouseleave', function() {
starBtns.forEach(function(s) {
const val = parseInt(s.getAttribute('data-value'));
if (val <= selectedRating) {
s.style.color = '#f5a623';
} else {
s.style.color = '#ddd';
}
});
});
});
// Submit rating
submitRatingBtn.addEventListener('click', function() {
ratingError.style.display = 'none';
if (selectedRating === 0) {
ratingError.textContent = 'Por favor, selecione pelo menos 1 estrela.';
ratingError.style.display = 'block';
return;
}
submitRatingBtn.disabled = true;
submitRatingBtn.innerHTML = '<i class="bi bi-hourglass me-1"></i>Enviando...';
fetch('/chatbot/rate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
session_id: sessionId,
rating: selectedRating,
comment: ratingComment.value.trim()
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
showThankyouView();
} else {
ratingError.textContent = data.message || 'Erro ao enviar avaliação.';
ratingError.style.display = 'block';
}
})
.catch(() => {
ratingError.textContent = 'Erro de conexão. Tente novamente.';
ratingError.style.display = 'block';
})
.finally(() => {
submitRatingBtn.disabled = false;
submitRatingBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Enviar Avaliação';
});
});
// Skip rating
skipRatingBtn.addEventListener('click', function() {
showThankyouView();
});
// New chat button
newChatBtn.addEventListener('click', function() {
resetChat();
nameInput.focus();
});
})();
</script>
@endif
+62
View File
@@ -0,0 +1,62 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Chatbot\Http\Controllers\ChatbotApiController;
use App\Modules\Chatbot\Http\Controllers\Admin\ChatbotController;
/*
|--------------------------------------------------------------------------
| Chatbot Module - Public Routes (API endpoints for AJAX)
|--------------------------------------------------------------------------
*/
Route::post('/chatbot/register', [ChatbotApiController::class, 'registerVisitor'])
->middleware('throttle:10,1')
->name('chatbot.register');
Route::post('/chatbot/send', [ChatbotApiController::class, 'sendMessage'])
->middleware('throttle:30,1')
->name('chatbot.send');
Route::post('/chatbot/end', [ChatbotApiController::class, 'endConversation'])
->middleware('throttle:10,1')
->name('chatbot.end');
Route::post('/chatbot/rate', [ChatbotApiController::class, 'rateConversation'])
->middleware('throttle:5,1')
->name('chatbot.rate');
Route::get('/chatbot/status', [ChatbotApiController::class, 'getStatus'])
->middleware('throttle:60,1')
->name('chatbot.status');
/*
|--------------------------------------------------------------------------
| Chatbot Module - Admin Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
// Chatbot management (permission: chatbot)
Route::middleware('permission:chatbot')->group(function () {
Route::get('/chatbot', [ChatbotController::class, 'index'])->name('chatbot.index');
Route::get('/chatbot/criar', [ChatbotController::class, 'create'])->name('chatbot.create');
Route::post('/chatbot', [ChatbotController::class, 'store'])->name('chatbot.store');
Route::get('/chatbot/{chatbot_knowledge}/editar', [ChatbotController::class, 'edit'])->name('chatbot.edit');
Route::put('/chatbot/{chatbot_knowledge}', [ChatbotController::class, 'update'])->name('chatbot.update');
Route::delete('/chatbot/{chatbot_knowledge}', [ChatbotController::class, 'destroy'])->name('chatbot.destroy');
// Chatbot conversations (contacts)
Route::get('/chatbot/conversas', [ChatbotController::class, 'conversations'])->name('chatbot.conversations');
Route::get('/chatbot/conversas/{conversation}', [ChatbotController::class, 'showConversation'])->name('chatbot.conversation.show');
Route::delete('/chatbot/conversas/{conversation}', [ChatbotController::class, 'destroyConversation'])->name('chatbot.conversation.destroy');
// Chatbot settings
Route::get('/chatbot/configuracoes', [ChatbotController::class, 'settings'])->name('chatbot.settings');
Route::post('/chatbot/configuracoes', [ChatbotController::class, 'updateSettings'])->name('chatbot.settings.update');
// Chatbot AI rules (configurable behavior rules)
Route::get('/chatbot/regras', [ChatbotController::class, 'rulesIndex'])->name('chatbot.rules.index');
Route::get('/chatbot/regras/criar', [ChatbotController::class, 'rulesCreate'])->name('chatbot.rules.create');
Route::post('/chatbot/regras', [ChatbotController::class, 'rulesStore'])->name('chatbot.rules.store');
Route::get('/chatbot/regras/{rule}/editar', [ChatbotController::class, 'rulesEdit'])->name('chatbot.rules.edit');
Route::put('/chatbot/regras/{rule}', [ChatbotController::class, 'rulesUpdate'])->name('chatbot.rules.update');
Route::delete('/chatbot/regras/{rule}', [ChatbotController::class, 'rulesDestroy'])->name('chatbot.rules.destroy');
});
});
@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Products\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Products\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProductCategoryController extends Controller
{
public function index()
{
$categories = ProductCategory::orderBy('order')->paginate(10);
return view('products::admin.categories.index', compact('categories'));
}
public function create()
{
return view('products::admin.categories.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:product_categories',
'description' => 'nullable|string',
'image' => 'nullable|image|max:2048',
'active' => 'required|boolean',
'order' => 'required|integer',
]);
$data = $request->all();
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('categories', 'public');
}
ProductCategory::create($data);
return redirect()->route('admin.categories.index')->with('success', 'Categoria criada com sucesso.');
}
public function edit(ProductCategory $category)
{
return view('products::admin.categories.edit', compact('category'));
}
public function update(Request $request, ProductCategory $category)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:product_categories,slug,' . $category->id,
'description' => 'nullable|string',
'image' => 'nullable|image|max:2048',
'active' => 'required|boolean',
'order' => 'required|integer',
]);
$data = $request->all();
if ($request->hasFile('image')) {
if ($category->image) {
Storage::disk('public')->delete($category->image);
}
$data['image'] = $request->file('image')->store('categories', 'public');
}
$category->update($data);
return redirect()->route('admin.categories.index')->with('success', 'Categoria atualizada com sucesso.');
}
public function destroy(ProductCategory $category)
{
if ($category->products()->count() > 0) {
return redirect()->back()->with('error', 'Esta categoria possui produtos vinculados e não pode ser excluída.');
}
if ($category->image) {
Storage::disk('public')->delete($category->image);
}
$category->delete();
return redirect()->route('admin.categories.index')->with('success', 'Categoria excluída com sucesso.');
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Modules\Products\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Products\Models\Product;
use App\Modules\Products\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use App\Models\User;
use App\Modules\Products\Notifications\ProductActionNotification;
class ProductController extends Controller
{
public function index()
{
$products = Product::with('category')->latest()->paginate(10);
return view('products::admin.products.index', compact('products'));
}
public function create()
{
$categories = ProductCategory::where('active', true)->orderBy('order')->get();
return view('products::admin.products.create', compact('categories'));
}
public function store(Request $request)
{
$request->validate([
'category_id' => 'nullable|exists:product_categories,id',
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:products',
'description' => 'nullable|string',
'price' => 'nullable|numeric',
'image' => 'nullable|image|max:2048',
'active' => 'required|boolean',
]);
$data = $request->all();
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('products', 'public');
}
$product = Product::create($data);
// Notify administrators
$admins = User::where('user_type', 'Admin')->get();
Notification::send($admins, new ProductActionNotification($product, Auth::user(), 'created'));
return redirect()->route('admin.products.index')->with('success', 'Produto criado com sucesso.');
}
public function edit(Product $product)
{
$categories = ProductCategory::where('active', true)->orderBy('order')->get();
return view('products::admin.products.edit', compact('product', 'categories'));
}
public function update(Request $request, Product $product)
{
$request->validate([
'category_id' => 'nullable|exists:product_categories,id',
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:products,slug,' . $product->id,
'description' => 'nullable|string',
'price' => 'nullable|numeric',
'image' => 'nullable|image|max:2048',
'active' => 'required|boolean',
]);
$data = $request->all();
if ($request->hasFile('image')) {
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
$data['image'] = $request->file('image')->store('products', 'public');
}
$product->update($data);
// Notify administrators
$admins = User::where('user_type', 'Admin')->get();
Notification::send($admins, new ProductActionNotification($product, Auth::user(), 'updated'));
return redirect()->route('admin.products.index')->with('success', 'Produto atualizado com sucesso.');
}
public function destroy(Product $product)
{
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
$product->delete();
return redirect()->route('admin.products.index')->with('success', 'Produto excluído com sucesso.');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Products\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Page;
use App\Modules\Products\Models\Product;
use App\Modules\Products\Models\ProductCategory;
class ProductPageController extends Controller
{
public function produtos(Request $request)
{
$page = Page::where('slug', 'produtos')->where('active', true)->firstOrFail();
$categories = ProductCategory::where('active', true)
->whereHas('products', function ($q) {
$q->where('active', true);
})
->orderBy('order')
->get();
$query = Product::where('active', true);
if ($request->has('categoria') && $request->categoria != '') {
$query->whereHas('category', function ($q) use ($request) {
$q->where('slug', $request->categoria);
});
}
$products = $query->latest()->get();
$currentCategory = null;
if ($request->has('categoria') && $request->categoria != '') {
$currentCategory = ProductCategory::where('slug', $request->categoria)->firstOrFail();
}
return view('products::produtos', compact('page', 'products', 'categories', 'currentCategory'));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Modules\Products\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
protected $fillable = ['category_id', 'name', 'slug', 'description', 'price', 'image', 'active'];
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'category_id');
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Modules\Products\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductCategory extends Model
{
protected $fillable = ['name', 'slug', 'description', 'image', 'active', 'order'];
public function products(): HasMany
{
return $this->hasMany(Product::class, 'category_id');
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Products\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Modules\Products\Models\Product;
use App\Models\User;
class ProductActionNotification extends Notification
{
use Queueable;
protected $product;
protected $user;
protected $action; // 'created' or 'updated'
/**
* Create a new notification instance.
*/
public function __construct(Product $product, User $user, string $action)
{
$this->product = $product;
$this->user = $user;
$this->action = $action;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
// If admin wants to receive product change notifications
if ($prefs['notifications_product_actions'] ?? true) {
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$actionText = $this->action === 'created' ? 'criado' : 'atualizado';
return (new MailMessage)
->subject('Produto ' . ucfirst($actionText) . ' - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Um produto do site foi ' . $actionText . ' por ' . $this->user->name . '.')
->line('**Nome do Produto:** ' . $this->product->name)
->line('**Preço:** R$ ' . number_format($this->product->price, 2, ',', '.'))
->action('Gerenciar Produtos', route('admin.products.index'))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
$actionText = $this->action === 'created' ? 'criado' : 'atualizado';
return [
'product_id' => $this->product->id,
'name' => $this->user->name,
'email' => $this->user->email,
'action' => $this->action,
'message' => "Produto '{$this->product->name}' foi {$actionText}.",
'link' => route('admin.products.edit', $this->product->id)
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Products\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
/**
* Products Module Service Provider
*
* Registers all Products module resources when the module is enabled:
* - Routes (public and admin)
* - Views (namespaced as 'products::')
*/
class ProductsServiceProvider extends ServiceProvider
{
/**
* The module identifier.
*/
protected string $moduleName = 'products';
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Register module views
$this->loadViewsFrom(
__DIR__ . '/../resources/views',
'products'
);
// Register module routes
Route::middleware(['web'])
->group(__DIR__ . '/../routes/web.php');
}
}
@@ -0,0 +1,102 @@
@extends('layouts.admin')
@section('title', 'Nova Categoria')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Nova Categoria</h1>
<a href="{{ route('admin.categories.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações da Categoria</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.categories.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome da Categoria</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name') }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug') }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="5">{{ old('description') }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem da Categoria</label>
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Ordem</label>
<input type="number" name="order" class="form-control @error('order') is-invalid @enderror" value="{{ old('order', 0) }}" required>
@error('order') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', '1') == '1' ? 'selected' : '' }}>Ativa</option>
<option value="0" {{ old('active') == '0' ? 'selected' : '' }}>Inativa</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Categoria
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 200,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,109 @@
@extends('layouts.admin')
@section('title', 'Editar Categoria')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Editar Categoria: {{ $category->name }}</h1>
<a href="{{ route('admin.categories.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações da Categoria</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.categories.update', $category) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome da Categoria</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $category->name) }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug', $category->slug) }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="5">{{ old('description', $category->description) }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem da Categoria</label>
@if($category->image)
<div class="mb-2">
<img src="{{ asset('storage/' . $category->image) }}" class="img-thumbnail" style="height: 100px;">
</div>
@endif
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
<small class="text-muted">Deixe em branco para manter a imagem atual.</small>
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Ordem</label>
<input type="number" name="order" class="form-control @error('order') is-invalid @enderror" value="{{ old('order', $category->order) }}" required>
@error('order') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', $category->active) == '1' ? 'selected' : '' }}>Ativa</option>
<option value="0" {{ old('active', $category->active) == '0' ? 'selected' : '' }}>Inativa</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Alterações
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 200,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,78 @@
@extends('layouts.admin')
@section('title', 'Categorias de Produtos')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Categorias de Produtos</h1>
<a href="{{ route('admin.categories.create') }}" class="btn btn-sm btn-primary shadow-sm">
<i class="bi bi-plus-lg text-white-50"></i> Nova Categoria
</a>
</div>
@if(session('success'))
<div class="alert alert-success border-0 shadow-sm mb-4">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger border-0 shadow-sm mb-4">{{ session('error') }}</div>
@endif
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Lista de Categorias</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th width="50">Ordem</th>
<th width="100">Imagem</th>
<th>Nome</th>
<th>Slug</th>
<th>Status</th>
<th width="150">Ações</th>
</tr>
</thead>
<tbody>
@foreach($categories as $category)
<tr>
<td>{{ $category->order }}</td>
<td>
@if($category->image)
<img src="{{ asset('storage/' . $category->image) }}" class="img-thumbnail" style="height: 50px;">
@else
<span class="badge badge-light">Sem foto</span>
@endif
</td>
<td>{{ $category->name }}</td>
<td>{{ $category->slug }}</td>
<td>
<span class="badge badge-{{ $category->active ? 'success' : 'secondary' }}">
{{ $category->active ? 'Ativa' : 'Inativa' }}
</span>
</td>
<td>
<a href="{{ route('admin.categories.edit', $category) }}" class="btn btn-sm btn-primary">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('admin.categories.destroy', $category) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Excluir esta categoria? Certifique-se de que não há produtos vinculados.')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
{{ $categories->links() }}
</div>
</div>
</div>
@endsection
@@ -0,0 +1,116 @@
@extends('layouts.admin')
@section('title', 'Novo Produto')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Novo Produto</h1>
<a href="{{ route('admin.products.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações do Produto</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.products.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome do Produto</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name') }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug') }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="10">{{ old('description') }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Categoria</label>
<select name="category_id" class="form-control @error('category_id') is-invalid @enderror">
<option value="">Sem Categoria</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Preço (R$)</label>
<input type="number" name="price" step="0.01" class="form-control @error('price') is-invalid @enderror" value="{{ old('price') }}">
@error('price') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem do Produto</label>
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', '1') == '1' ? 'selected' : '' }}>Ativo</option>
<option value="0" {{ old('active') == '0' ? 'selected' : '' }}>Inativo</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Produto
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 300,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,123 @@
@extends('layouts.admin')
@section('title', 'Editar Produto')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Editar Produto: {{ $product->name }}</h1>
<a href="{{ route('admin.products.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações do Produto</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.products.update', $product) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome do Produto</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $product->name) }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug', $product->slug) }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="10">{{ old('description', $product->description) }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Categoria</label>
<select name="category_id" class="form-control @error('category_id') is-invalid @enderror">
<option value="">Sem Categoria</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Preço (R$)</label>
<input type="number" name="price" step="0.01" class="form-control @error('price') is-invalid @enderror" value="{{ old('price', $product->price) }}">
@error('price') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem do Produto</label>
@if($product->image)
<div class="mb-2">
<img src="{{ asset('storage/' . $product->image) }}" class="img-thumbnail" style="height: 100px;">
</div>
@endif
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
<small class="text-muted">Deixe em branco para manter a imagem atual.</small>
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', $product->active) == '1' ? 'selected' : '' }}>Ativo</option>
<option value="0" {{ old('active', $product->active) == '0' ? 'selected' : '' }}>Inativo</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Alterações
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 300,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,80 @@
@extends('layouts.admin')
@section('title', 'Gerenciar Produtos')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Gerenciamento de Produtos</h1>
<a href="{{ route('admin.products.create') }}" class="btn btn-sm btn-primary shadow-sm">
<i class="bi bi-plus-lg text-white-50"></i> Novo Produto
</a>
</div>
@if(session('success'))
<div class="alert alert-success border-0 shadow-sm mb-4">{{ session('success') }}</div>
@endif
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Lista de Produtos</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th width="100">Imagem</th>
<th>Nome</th>
<th>Categoria</th>
<th>Preço</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
@foreach($products as $product)
<tr>
<td>
@if($product->image)
<img src="{{ asset('storage/' . $product->image) }}" class="img-thumbnail" style="height: 50px;">
@else
<span class="badge badge-light">Sem foto</span>
@endif
</td>
<td>{{ $product->name }}</td>
<td>
@if($product->category)
<span class="badge badge-info">{{ $product->category->name }}</span>
@else
<span class="badge badge-light">Sem Categoria</span>
@endif
</td>
<td>R$ {{ number_format($product->price, 2, ',', '.') }}</td>
<td>
<span class="badge badge-{{ $product->active ? 'success' : 'secondary' }}">
{{ $product->active ? 'Ativo' : 'Inativo' }}
</span>
</td>
<td>
<a href="{{ route('admin.products.edit', $product) }}" class="btn btn-sm btn-primary">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('admin.products.destroy', $product) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Excluir este produto?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
{{ $products->links() }}
</div>
</div>
</div>
@endsection
+147
View File
@@ -0,0 +1,147 @@
@extends('layouts.app')
@section('title', $page->title)
@section('meta_description', $page->meta_description)
@section('meta_keywords', $page->meta_keywords)
@section('og_title', $page->og_title)
@section('og_description', $page->og_description)
@section('og_image', $page->og_image)
@section('canonical', $page->canonical_url)
@section('content')
<div class="container py-5">
<div class="text-center mb-5">
<h1 class="display-4 fw-bold text-primary">{{ $page->title }}</h1>
<div class="lead text-muted">{!! $page->content !!}</div>
</div>
<!-- Filtro de Categorias -->
@if($categories->count() > 0)
<div class="row mb-5">
<div class="col-12 text-center">
<div class="d-flex flex-wrap justify-content-center gap-2">
<a href="{{ route('produtos') }}" class="btn btn-{{ !request()->has('categoria') || request()->categoria == '' ? 'primary' : 'outline-primary' }} rounded-pill px-4">
Todos
</a>
@foreach($categories as $category)
<a href="{{ route('produtos', ['categoria' => $category->slug]) }}" class="btn btn-{{ request()->categoria == $category->slug ? 'primary' : 'outline-primary' }} rounded-pill px-4">
{{ $category->name }}
</a>
@endforeach
</div>
</div>
</div>
@endif
<!-- Detalhes da Categoria Atual -->
@if($currentCategory)
<div class="row mb-5 align-items-center">
@if($currentCategory->image)
<div class="col-md-4">
<img src="{{ asset('storage/' . $currentCategory->image) }}" class="img-fluid rounded shadow-sm" alt="{{ $currentCategory->name }}">
</div>
@endif
<div class="{{ $currentCategory->image ? 'col-md-8' : 'col-12 text-center' }}">
<h2 class="fw-bold text-dark">{{ $currentCategory->name }}</h2>
@if($currentCategory->description)
<div class="text-muted lead">{!! $currentCategory->description !!}</div>
@endif
</div>
</div>
@endif
<div class="row g-4">
@forelse($products as $product)
<div class="col-md-4 col-lg-3">
<div class="card h-100 border-0 shadow-sm overflow-hidden product-card">
<div class="position-relative">
@if($product->image)
<img src="{{ asset('storage/' . $product->image) }}" class="card-img-top" alt="{{ $product->name }}" style="height: 250px; object-fit: cover;">
@else
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 250px;">
<i class="bi bi-box-seam display-1 text-muted"></i>
</div>
@endif
@if($product->price)
<div class="position-absolute bottom-0 end-0 bg-primary text-white px-3 py-2 fw-bold rounded-start-pill shadow">
R$ {{ number_format($product->price, 2, ',', '.') }}
</div>
@endif
</div>
<div class="card-body p-4">
<h5 class="card-title fw-bold text-dark mb-3">{{ $product->name }}</h5>
<div class="card-text text-muted small mb-4">
{!! Str::limit(strip_tags($product->description), 100) !!}
</div>
<div class="d-grid">
<button class="btn btn-outline-primary rounded-pill fw-bold" data-bs-toggle="modal" data-bs-target="#productModal{{ $product->id }}">
<i class="bi bi-info-circle me-1"></i> Detalhes
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Detalhes do Produto -->
<div class="modal fade" id="productModal{{ $product->id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow-lg rounded-4 overflow-hidden">
<div class="modal-body p-0">
<div class="row g-0">
<div class="col-lg-6">
@if($product->image)
<img src="{{ asset('storage/' . $product->image) }}" class="img-fluid h-100" style="object-fit: cover; min-height: 400px;">
@else
<div class="bg-light d-flex align-items-center justify-content-center h-100" style="min-height: 400px;">
<i class="bi bi-box-seam display-1 text-muted"></i>
</div>
@endif
</div>
<div class="col-lg-6 p-5">
<button type="button" class="btn-close float-end" data-bs-dismiss="modal" aria-label="Close"></button>
<h2 class="fw-bold text-primary mb-3">{{ $product->name }}</h2>
@if($product->price)
<h3 class="text-dark fw-bold mb-4">R$ {{ number_format($product->price, 2, ',', '.') }}</h3>
@endif
<div class="text-muted mb-5">
{!! $product->description !!}
</div>
<div class="d-grid gap-3">
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de mais informações sobre o produto: {{ $product->name }}" target="_blank" class="btn btn-success btn-lg rounded-pill fw-bold">
<i class="bi bi-whatsapp me-2"></i> Tenho Interesse
</a>
<a href="{{ route('contato') }}" class="btn btn-outline-primary btn-lg rounded-pill fw-bold">
Solicitar Orçamento
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="col-12 text-center py-5">
<i class="bi bi-box-seam fs-1 text-muted d-block mb-3"></i>
<p class="text-muted lead">Nenhum produto cadastrado no momento.</p>
</div>
@endforelse
</div>
</div>
@endsection
@push('css')
<style>
.product-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.product-card:hover {
transform: translateY(-10px);
box-shadow: 0 1rem 3rem rgba(0,0,0,.1) !important;
}
.rounded-start-pill {
border-top-left-radius: 50px;
border-bottom-left-radius: 50px;
}
</style>
@endpush
+39
View File
@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Products\Http\Controllers\ProductPageController;
use App\Modules\Products\Http\Controllers\Admin\ProductController;
use App\Modules\Products\Http\Controllers\Admin\ProductCategoryController;
/*
|--------------------------------------------------------------------------
| Products Module - Public Routes
|--------------------------------------------------------------------------
*/
Route::get('/produtos', [ProductPageController::class, 'produtos'])->name('produtos');
/*
|--------------------------------------------------------------------------
| Products Module - Admin Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
// Products management (permission: products)
Route::middleware('permission:products')->group(function () {
Route::get('/produtos/categorias', [ProductCategoryController::class, 'index'])->name('categories.index');
Route::get('/produtos/categorias/criar', [ProductCategoryController::class, 'create'])->name('categories.create');
Route::post('/produtos/categorias', [ProductCategoryController::class, 'store'])->name('categories.store');
Route::get('/produtos/categorias/{category}/editar', [ProductCategoryController::class, 'edit'])->name('categories.edit');
Route::put('/produtos/categorias/{category}', [ProductCategoryController::class, 'update'])->name('categories.update');
Route::delete('/produtos/categorias/{category}', [ProductCategoryController::class, 'destroy'])->name('categories.destroy');
Route::get('/produtos', [ProductController::class, 'index'])->name('products.index');
Route::get('/produtos/criar', [ProductController::class, 'create'])->name('products.create');
Route::post('/produtos', [ProductController::class, 'store'])->name('products.store');
Route::get('/produtos/{product}/editar', [ProductController::class, 'edit'])->name('products.edit');
Route::put('/produtos/{product}', [ProductController::class, 'update'])->name('products.update');
Route::delete('/produtos/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
});
});
@@ -0,0 +1,85 @@
<?php
namespace App\Modules\Services\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Modules\Services\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ServiceController extends Controller
{
public function index()
{
$services = Service::orderBy('order')->paginate(10);
return view('services::admin.services.index', compact('services'));
}
public function create()
{
return view('services::admin.services.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:services',
'description' => 'nullable|string',
'icon' => 'nullable|string|max:100',
'image' => 'nullable|image|max:2048',
'order' => 'required|integer',
'active' => 'required|boolean',
]);
$data = $request->all();
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('services', 'public');
}
Service::create($data);
return redirect()->route('admin.services.index')->with('success', 'Serviço criado com sucesso.');
}
public function edit(Service $service)
{
return view('services::admin.services.edit', compact('service'));
}
public function update(Request $request, Service $service)
{
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:services,slug,' . $service->id,
'description' => 'nullable|string',
'icon' => 'nullable|string|max:100',
'image' => 'nullable|image|max:2048',
'order' => 'required|integer',
'active' => 'required|boolean',
]);
$data = $request->all();
if ($request->hasFile('image')) {
if ($service->image) {
Storage::disk('public')->delete($service->image);
}
$data['image'] = $request->file('image')->store('services', 'public');
}
$service->update($data);
return redirect()->route('admin.services.index')->with('success', 'Serviço atualizado com sucesso.');
}
public function destroy(Service $service)
{
if ($service->image) {
Storage::disk('public')->delete($service->image);
}
$service->delete();
return redirect()->route('admin.services.index')->with('success', 'Serviço excluído com sucesso.');
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Modules\Services\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Page;
use App\Modules\Services\Models\Service;
class ServicePageController extends Controller
{
public function servicos()
{
$page = Page::where('slug', 'servicos')->where('active', true)->firstOrFail();
$services = Service::where('active', true)->orderBy('order')->get();
return view('services::servicos', compact('page', 'services'));
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Modules\Services\Models;
use Illuminate\Database\Eloquent\Model;
class Service extends Model
{
protected $fillable = ['name', 'slug', 'description', 'icon', 'image', 'order', 'active'];
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Services\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
/**
* Services Module Service Provider
*
* Registers all Services module resources when the module is enabled:
* - Routes (public and admin)
* - Views (namespaced as 'services::')
*/
class ServicesServiceProvider extends ServiceProvider
{
/**
* The module identifier.
*/
protected string $moduleName = 'services';
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Register module views
$this->loadViewsFrom(
__DIR__ . '/../resources/views',
'services'
);
// Register module routes
Route::middleware(['web'])
->group(__DIR__ . '/../routes/web.php');
}
}
@@ -0,0 +1,110 @@
@extends('layouts.admin')
@section('title', 'Novo Serviço')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Novo Serviço</h1>
<a href="{{ route('admin.services.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações do Serviço</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.services.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome do Serviço</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name') }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug') }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição Detalhada</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="10">{{ old('description') }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Ícone (Classe CSS)</label>
<input type="text" name="icon" class="form-control @error('icon') is-invalid @enderror" value="{{ old('icon', 'bi bi-tools') }}" placeholder="Ex: bi bi-tools">
<small class="text-muted">Use <a href="https://icons.getbootstrap.com/" target="_blank">Bootstrap Icons</a> .</small>
@error('icon') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem de Capa (Opcional)</label>
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Ordem</label>
<input type="number" name="order" class="form-control @error('order') is-invalid @enderror" value="{{ old('order', 0) }}" required>
@error('order') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', '1') == '1' ? 'selected' : '' }}>Ativo</option>
<option value="0" {{ old('active') == '0' ? 'selected' : '' }}>Inativo</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Serviço
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 300,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,117 @@
@extends('layouts.admin')
@section('title', 'Editar Serviço')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Editar Serviço: {{ $service->name }}</h1>
<a href="{{ route('admin.services.index') }}" class="btn btn-sm btn-secondary shadow-sm">
<i class="bi bi-arrow-left text-white-50"></i> Voltar
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informações do Serviço</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.services.update', $service) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label class="font-weight-bold">Nome do Serviço</label>
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $service->name) }}" required>
@error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Slug (URL)</label>
<input type="text" name="slug" id="slug" class="form-control @error('slug') is-invalid @enderror" value="{{ old('slug', $service->slug) }}" required>
@error('slug') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Descrição Detalhada</label>
<textarea name="description" id="editor" class="form-control @error('description') is-invalid @enderror" rows="10">{{ old('description', $service->description) }}</textarea>
@error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
<div class="col-md-4">
<div class="form-group mb-3">
<label class="font-weight-bold">Ícone (Classe CSS)</label>
<input type="text" name="icon" class="form-control @error('icon') is-invalid @enderror" value="{{ old('icon', $service->icon) }}" placeholder="Ex: bi bi-tools">
<small class="text-muted">Use <a href="https://icons.getbootstrap.com/" target="_blank">Bootstrap Icons</a> .</small>
@error('icon') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Imagem de Capa (Opcional)</label>
@if($service->image)
<div class="mb-2">
<img src="{{ asset('storage/' . $service->image) }}" class="img-thumbnail" style="height: 100px;">
</div>
@endif
<input type="file" name="image" class="form-control-file @error('image') is-invalid @enderror">
<small class="text-muted">Deixe em branco para manter a imagem atual.</small>
@error('image') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Ordem</label>
<input type="number" name="order" class="form-control @error('order') is-invalid @enderror" value="{{ old('order', $service->order) }}" required>
@error('order') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group mb-3">
<label class="font-weight-bold">Status</label>
<select name="active" class="form-control">
<option value="1" {{ old('active', $service->active) == '1' ? 'selected' : '' }}>Ativo</option>
<option value="0" {{ old('active', $service->active) == '0' ? 'selected' : '' }}>Inativo</option>
</select>
</div>
</div>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block py-3 fw-bold shadow-sm">
<i class="bi bi-floppy me-1"></i> Salvar Alterações
</button>
</form>
</div>
</div>
@endsection
@push('css')
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
@endpush
@push('js')
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
<script>
$(document).ready(function() {
$('#editor').summernote({
height: 300,
tabsize: 2,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['view', ['fullscreen', 'codeview']]
]
});
document.getElementById('name').addEventListener('keyup', function() {
let text = this.value.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
document.getElementById('slug').value = text;
});
});
</script>
@endpush
@@ -0,0 +1,72 @@
@extends('layouts.admin')
@section('title', 'Gerenciar Serviços')
@section('content')
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Gerenciamento de Serviços</h1>
<a href="{{ route('admin.services.create') }}" class="btn btn-sm btn-primary shadow-sm">
<i class="bi bi-plus-lg text-white-50"></i> Novo Serviço
</a>
</div>
@if(session('success'))
<div class="alert alert-success border-0 shadow-sm mb-4">{{ session('success') }}</div>
@endif
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Lista de Serviços</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th width="50">Ordem</th>
<th width="80">Ícone</th>
<th>Nome</th>
<th>Status</th>
<th width="120">Ações</th>
</tr>
</thead>
<tbody>
@foreach($services as $service)
<tr>
<td>{{ $service->order }}</td>
<td class="text-center">
@if($service->icon)
<i class="{{ $service->icon }} text-primary"></i>
@else
<span class="badge badge-light">Sem ícone</span>
@endif
</td>
<td>{{ $service->name }}</td>
<td>
<span class="badge badge-{{ $service->active ? 'success' : 'secondary' }}">
{{ $service->active ? 'Ativo' : 'Inativo' }}
</span>
</td>
<td>
<a href="{{ route('admin.services.edit', $service) }}" class="btn btn-sm btn-primary">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('admin.services.destroy', $service) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Excluir este serviço?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
{{ $services->links() }}
</div>
</div>
</div>
@endsection
+869
View File
@@ -0,0 +1,869 @@
@extends('layouts.app')
@section('title', $page->title)
@section('meta_description', $page->meta_description)
@section('meta_keywords', $page->meta_keywords)
@section('og_title', $page->og_title)
@section('og_description', $page->og_description)
@section('og_image', $page->og_image)
@section('canonical', $page->canonical_url)
@section('content')
<style>
.hero-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.hero-gradient-alt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.hero-gradient-tech {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.service-card {
transition: all 0.3s ease;
border-radius: 1.5rem;
}
.service-card:hover {
transform: translateY(-15px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.feature-icon {
width: 80px;
height: 80px;
transition: transform 0.3s ease;
}
.service-card:hover .feature-icon {
transform: scale(1.1) rotate(5deg);
}
.pulse-ring {
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
}
70% {
box-shadow: 0 0 0 20px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
}
}
.floating-animation {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rounded-3xl {
border-radius: 2rem !important;
}
.backdrop-blur-sm {
backdrop-filter: blur(4px);
}
</style>
<section class="hero-gradient min-vh-100 d-flex align-items-center position-relative overflow-hidden">
<div class="container py-5 position-relative z-2">
<div class="row align-items-center">
<div class="col-lg-8">
<div class="text-white animate-up">
<span class="badge bg-white bg-opacity-20 text-white px-4 py-2 rounded-pill fw-bold text-uppercase mb-4 tracking-wider">
<i class="bi bi-rocket me-2"></i>Inovação e Tecnologia
</span>
<h1 class="display-3 fw-bold mb-4 leading-tight">
Soluções Digitais de <span class="text-warning">Alta Performance</span>
</h1>
<p class="lead mb-4 opacity-90 fs-4">
Desenvolvemos sistemas inteligentes, frameworks robustos e infraestrutura de ponta para impulsionar a transformação digital da sua empresa.
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="#erp" class="btn btn-warning btn-lg rounded-pill px-5 py-3 fw-bold shadow-lg">
<i class="bi bi-graph-up me-2"></i>Conheça o SysERP
</a>
<a href="#contato" class="btn btn-outline-light btn-lg rounded-pill px-5 py-3 fw-bold">
<i class="bi bi-send me-2"></i>Solicitar Orçamento
</a>
</div>
</div>
</div>
<div class="col-lg-4 text-center mt-5 mt-lg-0">
<div class="floating-animation">
<div class="bg-white bg-opacity-10 backdrop-blur-sm rounded-3xl p-5 shadow-2xl border border-white border-opacity-20">
<i class="bi bi-layers text-white mb-4"></i>
<h3 class="text-white fw-bold">Syspanel Suite</h3>
<p class="text-white opacity-80">Excelência em cada detalhe</p>
</div>
</div>
</div>
</div>
</div>
<div class="position-absolute top-0 end-0 w-50 h-100 bg-white rounded-start-3xl" style="--bs-bg-opacity: .05; transform: skewX(-10deg);"></div>
</section>
<section class="py-5">
<div class="container">
<div class="text-center mb-5 animate-up">
<h2 class="display-4 fw-bold mb-4">Por Que Escolher a <span class="gradient-text">Syspanel</span>?</h2>
<p class="lead text-muted mx-auto" style="max-width: 800px;">
Somos especialistas em desenvolvimento de software e oferecemos soluções que transformam negócios através da tecnologia.
</p>
</div>
<div class="row g-4 mb-5">
<div class="col-lg-3 col-md-6 animate-up" style="animation-delay: 0.1s;">
<div class="text-center p-4 h-100 service-card">
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-4 pulse-ring feature-icon">
<i class="bi bi-rocket text-primary"></i>
</div>
<h4 class="h5 fw-bold mb-3">Inovação Constante</h4>
<p class="text-muted small mb-0">Tecnologia de ponta em todos os projetos para garantir sua competitividade.</p>
</div>
</div>
<div class="col-lg-3 col-md-6 animate-up" style="animation-delay: 0.2s;">
<div class="text-center p-4 h-100 service-card">
<div class="bg-success bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-4 pulse-ring feature-icon">
<i class="bi bi-shield-check text-success"></i>
</div>
<h4 class="h5 fw-bold mb-3">Segurança Robusta</h4>
<p class="text-muted small mb-0">Proteção avançada de dados e sistemas seguindo os mais altos padrões.</p>
</div>
</div>
<div class="col-lg-3 col-md-6 animate-up" style="animation-delay: 0.3s;">
<div class="text-center p-4 h-100 service-card">
<div class="bg-warning bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-4 pulse-ring feature-icon">
<i class="bi bi-headset text-warning"></i>
</div>
<h4 class="h5 fw-bold mb-3">Suporte Especializado</h4>
<p class="text-muted small mb-0">Equipe técnica pronta para auxiliar sua empresa em qualquer desafio.</p>
</div>
</div>
<div class="col-lg-3 col-md-6 animate-up" style="animation-delay: 0.4s;">
<div class="text-center p-4 h-100 service-card">
<div class="bg-info bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-4 pulse-ring feature-icon">
<i class="bi bi-trophy text-info"></i>
</div>
<h4 class="h5 fw-bold mb-3">Qualidade Garantida</h4>
<p class="text-muted small mb-0">Entrega de projetos com excelência técnica e foco em resultados reais.</p>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.65s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-layers"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">SysSiteCMS CMS</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">Gestão de Conteúdo</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Plataforma completa de CMS para gestão de sites institucionais, catálogos de produtos e marketing digital integrado.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de Produtos, Serviços e Galerias</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Marketing: Banners, FAQ e Email Marketing</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Central de Downloads e Suporte Técnico</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Análise de Visitantes e SEO Avançado</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="https://syssite.syspanel.com.br" target="_blank" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar SysSiteCMS
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre as funcionalidades do SysSiteCMS CMS" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="erp" class="py-5 bg-light">
<div class="container">
<div class="text-center mb-5 animate-up">
<span class="badge bg-primary bg-opacity-10 text-primary px-4 py-2 rounded-pill fw-bold text-uppercase mb-3">
<i class="bi bi-graph-up me-2"></i>Gestão Empresarial
</span>
<h2 class="display-4 fw-bold">Sistemas ERP <span class="gradient-text">Inteligentes</span></h2>
<p class="lead text-muted">Plataformas completas para integrar e otimizar todos os processos do seu negócio</p>
</div>
<div class="row g-4">
<div class="col-lg-6 animate-up" style="animation-delay: 0.1s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-database"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">SysERP</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">ERP Completo</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Plataforma online desenvolvida para interligar e auxiliar na organização e gestão de diversas áreas da empresa, possibilitando integrações e controles nos processos gerenciais.
</p>
<h5 class="fw-bold mb-3">Funcionalidades Principais:</h5>
<div class="row g-2 mb-4">
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão Financeira</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Controle de Estoque</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de Vendas</li>
</ul>
</div>
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Produção e Serviços</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Relatórios Avançados</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Personalização</li>
</ul>
</div>
</div>
<div class="d-flex gap-3 flex-wrap">
<a href="https://erp.syspanel.com.br" target="_blank" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar SysERP
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre o SysERP" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.15s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-success me-4">
<i class="bi bi-cash-coin"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Fluxo de Caixa Pro</h3>
<span class="badge bg-success px-3 py-2 rounded-pill">Gestão Financeira Avançada</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Especializado em controle financeiro rigoroso, oferecendo ferramentas para gestão de fluxo de caixa, DRE gerencial e previsibilidade total para sua empresa.
</p>
<h5 class="fw-bold mb-3">Recursos Avançados:</h5>
<div class="row g-2 mb-4">
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>DRE Automático</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Conciliação OFX</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gráficos em Tempo Real</li>
</ul>
</div>
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Previsão de Saldo</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de Contas</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Multi-Empresa</li>
</ul>
</div>
</div>
<div class="d-flex gap-3 flex-wrap">
<a href="https://fluxodecaixapro.syspanel.com.br" target="_blank" class="btn btn-success rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar Fluxo de Caixa Pro
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre o Fluxo de Caixa Pro" target="_blank" class="btn btn-outline-success rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.2s;">
<div class="card border-0 shadow-lg h-100 hero-gradient rounded-3xl">
<div class="card-body p-5 text-white">
<h3 class="h3 fw-bold mb-4">Por que escolher o SysERP?</h3>
<div class="d-flex align-items-start mb-4">
<div class="bg-white bg-opacity-20 rounded-circle p-3 me-3">
<i class="bi bi-arrow-repeat"></i>
</div>
<div>
<h5 class="fw-bold mb-2">Integração em Tempo Real</h5>
<p class="opacity-90">Todos os setores da empresa conectados e sincronizados instantaneamente, eliminando falhas de comunicação.</p>
</div>
</div>
<div class="d-flex align-items-start mb-4">
<div class="bg-white bg-opacity-20 rounded-circle p-3 me-3">
<i class="bi bi-gear"></i>
</div>
<div>
<h5 class="fw-bold mb-2">Personalização Total</h5>
<p class="opacity-90">Adaptamos o sistema às necessidades específicas do seu negócio, criando módulos exclusivos se necessário.</p>
</div>
</div>
<div class="d-flex align-items-start">
<div class="bg-white bg-opacity-20 rounded-circle p-3 me-3">
<i class="bi bi-bar-chart"></i>
</div>
<div>
<h5 class="fw-bold mb-2">Relatórios Inteligentes</h5>
<p class="opacity-90">Dashboard completo com métricas e indicadores de performance para tomadas de decisão rápidas.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="frameworks" class="py-5">
<div class="container">
<div class="text-center mb-5 animate-up">
<span class="badge bg-info bg-opacity-10 text-info px-4 py-2 rounded-pill fw-bold text-uppercase mb-3">
<i class="bi bi-code-slash me-2"></i>Desenvolvimento Web
</span>
<h2 class="display-4 fw-bold">Frameworks PHP <span class="gradient-text">Profissionais</span></h2>
<p class="lead text-muted">Ferramentas poderosas para desenvolvimento de aplicações web modernas e escaláveis</p>
</div>
<div class="row g-4">
<div class="col-lg-4 animate-up" style="animation-delay: 0.1s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-4 text-center">
<div class="feature-icon bg-info bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center text-info mb-4">
<i class="bi bi-boxes"></i>
</div>
<h3 class="h4 fw-bold mb-3">SysFramework</h3>
<p class="text-muted mb-4">
Framework PHP moderno com arquitetura MVC modular, segurança robusta e ferramenta CLI potente.
</p>
<ul class="list-unstyled mb-4 text-start mx-auto" style="max-width: 200px;">
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Proteção CSRF, XSS</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>MVC Modular</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Acesso Robusto</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>SysCli Integrado</li>
</ul>
<a href="https://sysframework.syspanel.com.br" target="_blank" class="btn btn-info rounded-pill fw-bold w-100 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Explorar
</a>
</div>
</div>
</div>
<div class="col-lg-4 animate-up" style="animation-delay: 0.2s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-4 text-center">
<div class="feature-icon bg-danger bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center text-danger mb-4">
<i class="bi bi-diagram-2"></i>
</div>
<h3 class="h4 fw-bold mb-3">SysMVC</h3>
<p class="text-muted mb-4">
Framework PHP poderoso e flexível para desenvolvimento de aplicações web com arquitetura MVC clássica.
</p>
<ul class="list-unstyled mb-4 text-start mx-auto" style="max-width: 200px;">
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Arquitetura MVC</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Flexibilidade Total</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Projetos Complexos</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Desenvolvimento Ágil</li>
</ul>
<a href="https://sysmvc.syspanel.com.br" target="_blank" class="btn btn-danger rounded-pill fw-bold w-100 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Explorar
</a>
</div>
</div>
</div>
<div class="col-lg-4 animate-up" style="animation-delay: 0.3s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-4 text-center">
<div class="feature-icon bg-warning bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center text-warning mb-4">
<i class="bi bi-git"></i>
</div>
<h3 class="h4 fw-bold mb-3">SysVector</h3>
<p class="text-muted mb-4">
Framework PHP moderno com Bootstrap 5.3, Alpine.js e templates Twig para desenvolvimento ultra rápido.
</p>
<ul class="list-unstyled mb-4 text-start mx-auto" style="max-width: 200px;">
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Bootstrap 5.3</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Alpine.js</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Templates Twig</li>
<li class="mb-2"><i class="bi bi-check-lg text-success me-2"></i>Modernidade</li>
</ul>
<a href="https://sysvector.syspanel.com.br" target="_blank" class="btn btn-warning rounded-pill fw-bold w-100 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Explorar
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="py-5 bg-light">
<div class="container">
<div class="text-center mb-5 animate-up">
<span class="badge bg-success bg-opacity-10 text-success px-4 py-2 rounded-pill fw-bold text-uppercase mb-3">
<i class="bi bi-hdd-network me-2"></i>Hospedagem e CMS
</span>
<h2 class="display-4 fw-bold">Infraestrutura e <span class="gradient-text">Gestão</span></h2>
<p class="lead text-muted">Soluções completas de hospedagem profissional e sistemas de gerenciamento de conteúdo</p>
</div>
<div class="row g-4">
<div class="col-lg-6 animate-up" style="animation-delay: 0.1s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-success me-4">
<i class="bi bi-hdd-network"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">SysHost</h3>
<span class="badge bg-success px-3 py-2 rounded-pill">Hospedagem Profissional</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Hospedagem profissional com planos personalizados para sites, aplicativos, Laravel, WordPress e projetos SysMVC. Infraestrutura de alto desempenho.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Infraestrutura de alto desempenho</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Suporte especializado 24/7</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Planos flexíveis e personalizados</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Backup automático</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="https://host.syspanel.com.br" target="_blank" class="btn btn-success rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar SysHost
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de informações sobre hospedagem" target="_blank" class="btn btn-outline-success rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.2s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-newspaper"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">SysCMS</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">Gestão de Conteúdo</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Sistema de gerenciamento de conteúdo potente com suporte multi-idioma, SEO avançado e controle de acesso granular para sites modernos.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Arquitetura modular expansível</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Suporte nativo a 5 idiomas</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Otimização SEO avançada</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Controle de acesso granular</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="https://syscms.syspanel.com.br" target="_blank" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar SysCMS
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre o SysCMS" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="py-5">
<div class="container">
<div class="text-center mb-5 animate-up">
<span class="badge bg-primary bg-opacity-10 text-primary px-4 py-2 rounded-pill fw-bold text-uppercase mb-3">
<i class="bi bi-laptop me-2"></i>Serviços Adicionais
</span>
<h2 class="display-4 fw-bold">Soluções <span class="gradient-text">Complementares</span></h2>
<p class="lead text-muted">Serviços especializados para complementar sua infraestrutura digital</p>
</div>
<div class="row g-4">
<div class="col-lg-6 animate-up" style="animation-delay: 0.1s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-globe"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Registro de Domínios</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">Identidade Digital</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Registramos seu domínio nacional ou internacional para proteger sua identidade digital e garantir presença online profissional para sua empresa.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Registro nacional e internacional</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de DNS</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de renovações</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Proteção de identidade</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de registrar um domínio" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.15s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-registered"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Registro de Marcas</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">INPI</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Proteção legal da sua marca junto ao INPI. Garantimos a exclusividade de uso do seu nome e logotipo em todo o território nacional.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Pesquisa de viabilidade</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Protocolo de registro</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Acompanhamento processual</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Defesa e oposições</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de registrar uma marca no INPI" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.2s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-info me-4">
<i class="bi bi-envelope-open"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Emails Corporativos</h3>
<span class="badge bg-info px-3 py-2 rounded-pill">Comunicação</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Hospedagem de emails com seu domínio próprio. Infraestrutura robusta com proteção avançada e alta disponibilidade.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Emails personalizados</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Proteção antivírus e antispam</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Webmail moderno</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Sincronização mobile</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn btn-info rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de contratar hospedagem de emails" target="_blank" class="btn btn-outline-info rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.3s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-warning me-4">
<i class="bi bi-laptop"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Desenvolvimento Web</h3>
<span class="badge bg-warning px-3 py-2 rounded-pill">Web Design</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Criamos sites modernos, responsivos e otimizados para buscadores (SEO). Do site institucional ao e-commerce completo.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Sites responsivos</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Otimização SEO</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>E-commerce integrado</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Foco em conversão</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn btn-warning rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de um orçamento para desenvolvimento de site" target="_blank" class="btn btn-outline-warning rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.4s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-danger me-4">
<i class="bi bi-code-slash"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Softwares Sob Medida</h3>
<span class="badge bg-danger px-3 py-2 rounded-pill">Sistemas</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Desenvolvemos softwares personalizados para atender necessidades específicas. Sistemas desktop, web e mobile.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Sistemas personalizados</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Aplicativos mobile</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Integração de APIs</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Suporte contínuo</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn btn-danger rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de um orçamento para desenvolvimento de software" target="_blank" class="btn btn-outline-danger rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.5s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-purple bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-4" style="background-color: rgba(111, 66, 193, 0.1); color: #6f42c1;">
<i class="bi bi-robot"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">ChatBots com IA</h3>
<span class="badge px-3 py-2 rounded-pill" style="background-color: #6f42c1;">Inteligência Artificial</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Implementamos assistentes virtuais inteligentes para atendimento automático de clientes. ChatBots que respondem sobre sua empresa, produtos e serviços 24 horas por dia.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Atendimento 24/7 automático</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Base de conhecimento personalizada</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Integração com Inteligência Artificial</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Coleta de contatos e avaliações</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('contato') }}" class="btn rounded-pill fw-bold px-4 py-2" style="background-color: #6f42c1; color: white;">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre o serviço de implantação de ChatBot com IA" target="_blank" class="btn btn-outline-purple rounded-pill fw-bold px-4 py-2" style="color: #6f42c1; border-color: #6f42c1;">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.5s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-dark bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-dark me-4">
<i class="bi bi-terminal"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Servidores Linux</h3>
<span class="badge bg-dark px-3 py-2 rounded-pill">Local / Nuvem</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Implantação e configuração de servidores Linux em infraestrutura local ou nuvem. Alta disponibilidade, segurança e desempenho otimizado.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Implantação local e nuvem</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Configuração e hardening</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Monitoramento 24/7</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Alta disponibilidade</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('servidores-linux') }}" class="btn btn-dark rounded-pill fw-bold px-4 py-2">
<i class="bi bi-info-circle me-2"></i>Saiba Mais
</a>
<a href="{{ route('contato') }}" class="btn btn-outline-secondary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de informações sobre implantação de servidores Linux" target="_blank" class="btn btn-outline-dark rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.6s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-purple bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-4" style="color: #6f42c1;">
<i class="bi bi-robot"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">Assistentes Digitais com IA</h3>
<span class="badge px-3 py-2 rounded-pill" style="background-color: #6f42c1;">Inteligência Artificial</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Implantação de assistentes digitais inteligentes com inteligência artificial para automatizar atendimento, suporte e processos internos da sua empresa.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Chatbots inteligentes</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Automação de atendimento</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Integração com plataformas</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Personalização por negócio</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="{{ route('assistentes-digitais-ia') }}" class="btn rounded-pill fw-bold px-4 py-2" style="background-color: #6f42c1; color: #fff;">
<i class="bi bi-info-circle me-2"></i>Saiba Mais
</a>
<a href="{{ route('contato') }}" class="btn btn-outline-secondary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-envelope me-2"></i>Solicitar
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de informações sobre assistentes digitais com IA" target="_blank" class="btn rounded-pill fw-bold px-4 py-2" style="border: 1px solid #6f42c1; color: #6f42c1;">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.65s;">
<div class="card border-0 shadow-lg h-100 service-card">
<div class="card-body p-5">
<div class="d-flex align-items-center mb-4">
<div class="feature-icon bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center text-primary me-4">
<i class="bi bi-layers"></i>
</div>
<div>
<h3 class="h3 fw-bold mb-1">SysSiteCMS CMS</h3>
<span class="badge bg-primary px-3 py-2 rounded-pill">Gestão de Conteúdo</span>
</div>
</div>
<p class="text-muted mb-4 fs-5">
Plataforma completa de CMS para gestão de sites institucionais, catálogos de produtos e marketing digital integrado.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Gestão de Produtos, Serviços e Galerias</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Marketing: Banners, FAQ e Email Marketing</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Central de Downloads e Suporte Técnico</li>
<li class="mb-2"><i class="bi bi-check-circle text-success me-2"></i>Análise de Visitantes e SEO Avançado</li>
</ul>
<div class="d-flex gap-3 flex-wrap">
<a href="https://syssite.syspanel.com.br" target="_blank" class="btn btn-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-box-arrow-up-right me-2"></i>Acessar SysSiteCMS
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}?text=Olá! Gostaria de saber mais sobre as funcionalidades do SysSiteCMS CMS" target="_blank" class="btn btn-outline-primary rounded-pill fw-bold px-4 py-2">
<i class="bi bi-whatsapp me-2"></i>WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="py-5 hero-gradient">
<div class="container py-4">
<div class="row align-items-center">
<div class="col-lg-6 text-white mb-5 mb-lg-0 animate-up">
<h2 class="display-4 fw-bold mb-4">Precisa de Uma Solução <span class="text-warning">Personalizada</span>?</h2>
<p class="lead mb-4 opacity-90 fs-4">
Desenvolvemos sistemas sob medida para atender necessidades específicas do seu negócio. Nossa equipe tem experiência em projetos complexos e inovadores.
</p>
<ul class="list-unstyled mb-4">
<li class="mb-3 d-flex align-items-center">
<div class="bg-white bg-opacity-20 rounded-circle p-2 me-3">
<i class="bi bi-check-lg text-warning"></i>
</div>
<span class="fs-5">Análise completa dos requisitos</span>
</li>
<li class="mb-3 d-flex align-items-center">
<div class="bg-white bg-opacity-20 rounded-circle p-2 me-3">
<i class="bi bi-check-lg text-warning"></i>
</div>
<span class="fs-5">Desenvolvimento ágil e iterativo</span>
</li>
<li class="mb-3 d-flex align-items-center">
<div class="bg-white bg-opacity-20 rounded-circle p-2 me-3">
<i class="bi bi-check-lg text-warning"></i>
</div>
<span class="fs-5">Suporte contínuo e atualizações</span>
</li>
<li class="d-flex align-items-center">
<div class="bg-white bg-opacity-20 rounded-circle p-2 me-3">
<i class="bi bi-check-lg text-warning"></i>
</div>
<span class="fs-5">Treinamento completo da equipe</span>
</li>
</ul>
</div>
<div class="col-lg-6 animate-up" style="animation-delay: 0.2s;">
<div class="card border-0 shadow-2xl bg-white rounded-3xl overflow-hidden">
<div class="card-body p-5">
<div class="text-center mb-4">
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="bi bi-send text-primary"></i>
</div>
<h3 class="fw-bold mb-2">Solicite um Orçamento</h3>
<p class="text-muted">Entre em contato e descubra como podemos transformar seu negócio através da tecnologia.</p>
</div>
<div class="d-grid gap-3">
<a href="{{ route('contato') }}" class="btn btn-primary btn-lg rounded-pill fw-bold py-3 shadow-lg">
<i class="bi bi-envelope me-2"></i>Enviar Mensagem
</a>
<a href="https://wa.me/{{ preg_replace('/\D/', '', $company->phone) }}" target="_blank" class="btn btn-success btn-lg rounded-pill fw-bold py-3 shadow-lg">
<i class="bi bi-whatsapp me-2"></i>Falar no WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
@endsection
+31
View File
@@ -0,0 +1,31 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Services\Http\Controllers\ServicePageController;
use App\Modules\Services\Http\Controllers\Admin\ServiceController;
/*
|--------------------------------------------------------------------------
| Services Module - Public Routes
|--------------------------------------------------------------------------
*/
Route::get('/servicos', [ServicePageController::class, 'servicos'])->name('servicos');
/*
|--------------------------------------------------------------------------
| Services Module - Admin Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
// Services management (permission: services)
Route::middleware('permission:services')->group(function () {
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
Route::get('/servicos/criar', [ServiceController::class, 'create'])->name('services.create');
Route::post('/servicos', [ServiceController::class, 'store'])->name('services.store');
Route::get('/servicos/{service}/editar', [ServiceController::class, 'edit'])->name('services.edit');
Route::put('/servicos/{service}', [ServiceController::class, 'update'])->name('services.update');
Route::delete('/servicos/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
});
});
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\User;
class FeatureRequestNotification extends Notification
{
use Queueable;
protected $user;
protected $description;
/**
* Create a new notification instance.
*/
public function __construct($user, string $description)
{
$this->user = $user;
$this->description = $description;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Solicitação de Nova Funcionalidade - ' . config('app.name'))
->greeting('Olá, equipe SysSiteCMS!')
->line('Um usuário solicitou uma nova funcionalidade para o sistema.')
->line('**Dados do Usuário:**')
->line('- **Nome:** ' . $this->user->name)
->line('- **E-mail:** ' . $this->user->email)
->line('- **Empresa:** ' . ($this->user->company ?? 'Não informado'))
->line('**Necessidade Descrita:**')
->line($this->description)
->line('Favor entrar em contato com o cliente para orçamento.');
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\Gallery;
use App\Models\User;
class GalleryActionNotification extends Notification
{
use Queueable;
protected $gallery;
protected $user;
protected $action; // 'created', 'updated', 'photo_added', 'photo_deleted'
/**
* Create a new notification instance.
*/
public function __construct(Gallery $gallery, User $user, string $action)
{
$this->gallery = $gallery;
$this->user = $user;
$this->action = $action;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
if ($prefs['notifications_gallery_actions'] ?? true) {
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$actionMap = [
'created' => 'criada',
'updated' => 'atualizada',
'photo_added' => 'com nova foto adicionada',
'photo_deleted' => 'com foto removida',
];
$actionText = $actionMap[$this->action] ?? 'modificada';
return (new MailMessage)
->subject('Galeria ' . ucfirst($actionText) . ' - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Uma galeria de fotos foi ' . $actionText . ' por ' . $this->user->name . '.')
->line('**Título da Galeria:** ' . $this->gallery->title)
->action('Gerenciar Galerias', route('admin.galleries.index'))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
$actionMap = [
'created' => 'criada',
'updated' => 'atualizada',
'photo_added' => 'foto adicionada',
'photo_deleted' => 'foto removida',
];
$actionText = $actionMap[$this->action] ?? 'modificada';
return [
'gallery_id' => $this->gallery->id,
'name' => $this->user->name,
'email' => $this->user->email,
'action' => $this->action,
'message' => "Galeria '{$this->gallery->title}': {$actionText}.",
'link' => route('admin.galleries.edit', $this->gallery->id)
];
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\ContactMessage;
class NewContactMessageNotification extends Notification
{
use Queueable;
protected $contact;
/**
* Create a new notification instance.
*/
public function __construct(ContactMessage $contact)
{
$this->contact = $contact;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
// Verifica as preferências do usuário (notifiable)
$prefs = $notifiable->preferences ?? [];
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Novo Contato Recebido - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Você recebeu uma nova mensagem de contato através do site.')
->line('**Nome:** ' . $this->contact->name)
->line('**E-mail:** ' . $this->contact->email)
->line('**Mensagem:**')
->line($this->contact->message)
->action('Visualizar no Painel', route('admin.contacts.show', $this->contact->id))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'contact_id' => $this->contact->id,
'name' => $this->contact->name,
'email' => $this->contact->email,
'message' => 'Nova mensagem de contato recebida.',
'link' => route('admin.contacts.show', $this->contact->id)
];
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\User;
class NewUserRegisteredNotification extends Notification
{
use Queueable;
protected $newUser;
/**
* Create a new notification instance.
*/
public function __construct(User $newUser)
{
$this->newUser = $newUser;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
// Se o admin quiser receber notificações de novos usuários
if ($prefs['notifications_new_users'] ?? true) {
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Novo Usuário Cadastrado - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Um novo usuário acaba de se cadastrar no site.')
->line('**Nome:** ' . $this->newUser->name)
->line('**E-mail:** ' . $this->newUser->email)
->line('**Empresa:** ' . ($this->newUser->company ?? 'Não informado'))
->action('Gerenciar Usuários', route('admin.users.index'))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'user_id' => $this->newUser->id,
'name' => $this->newUser->name,
'email' => $this->newUser->email,
'message' => 'Novo usuário cadastrado: ' . $this->newUser->name,
'link' => route('admin.users.edit', $this->newUser->id)
];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\News;
use App\Models\User;
class NewsActionNotification extends Notification
{
use Queueable;
protected $news;
protected $user;
protected $action; // 'created', 'updated', 'photo_added', 'photo_deleted'
/**
* Create a new notification instance.
*/
public function __construct(News $news, User $user, string $action)
{
$this->news = $news;
$this->user = $user;
$this->action = $action;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$actionMap = [
'created' => 'criada',
'updated' => 'atualizada',
'photo_added' => 'com nova imagem adicionada',
'photo_deleted' => 'com imagem removida',
];
$actionText = $actionMap[$this->action] ?? 'modificada';
return (new MailMessage)
->subject('Notícia ' . ucfirst($actionText) . ' - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Uma notícia foi ' . $actionText . ' por ' . $this->user->name . '.')
->line('**Título:** ' . $this->news->title)
->action('Gerenciar Notícias', route('admin.news.index'))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
$actionMap = [
'created' => 'criada',
'updated' => 'atualizada',
'photo_added' => 'imagem adicionada',
'photo_deleted' => 'imagem removida',
];
$actionText = $actionMap[$this->action] ?? 'modificada';
return [
'news_id' => $this->news->id,
'name' => $this->user->name,
'email' => $this->user->email,
'action' => $this->action,
'message' => "Notícia '{$this->news->title}': {$actionText}.",
'link' => route('admin.news.edit', $this->news->id)
];
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Models\Page;
use App\Models\User;
class PageActionNotification extends Notification
{
use Queueable;
protected $page;
protected $user;
protected $action; // 'created' or 'updated'
/**
* Create a new notification instance.
*/
public function __construct(Page $page, User $user, string $action)
{
$this->page = $page;
$this->user = $user;
$this->action = $action;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->preferences ?? [];
// Se o admin quiser receber notificações de alterações em páginas
if ($prefs['notifications_page_actions'] ?? true) {
if ($prefs['notifications_email'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['notifications_system'] ?? true) {
$channels[] = 'database';
}
}
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$actionText = $this->action === 'created' ? 'criada' : 'atualizada';
return (new MailMessage)
->subject('Página ' . ucfirst($actionText) . ' - ' . config('app.name'))
->greeting('Olá, ' . $notifiable->name)
->line('Uma página do site foi ' . $actionText . ' por ' . $this->user->name . '.')
->line('**Título da Página:** ' . $this->page->title)
->line('**Slug:** ' . $this->page->slug)
->action('Gerenciar Páginas', route('admin.pages.index'))
->line('Obrigado por usar nosso sistema!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
$actionText = $this->action === 'created' ? 'criada' : 'atualizada';
return [
'page_id' => $this->page->id,
'name' => $this->user->name,
'email' => $this->user->email,
'action' => $this->action,
'message' => "Página '{$this->page->title}' foi {$actionText}.",
'link' => route('admin.pages.edit', $this->page->id)
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Support\ModuleManager;
/**
* Module Service Provider
*
* Dynamically loads enabled module service providers.
* Each module has its own ServiceProvider that registers
* routes, views, and other resources when the module is enabled.
*/
class ModuleServiceProvider extends ServiceProvider
{
/**
* Module service provider class mappings.
* Key = module identifier, Value = service provider class
*/
protected array $moduleProviders = [
'products' => \App\Modules\Products\Providers\ProductsServiceProvider::class,
'services' => \App\Modules\Services\Providers\ServicesServiceProvider::class,
'chatbot' => \App\Modules\Chatbot\Providers\ChatbotServiceProvider::class,
];
/**
* Register services.
*/
public function register(): void
{
// Register the ModuleManager as a singleton
$this->app->singleton(ModuleManager::class, function ($app) {
return new ModuleManager();
});
// Register enabled module service providers
$manager = $this->app->make(ModuleManager::class);
foreach ($this->moduleProviders as $module => $providerClass) {
if ($manager->isEnabled($module) && class_exists($providerClass)) {
$this->app->register($providerClass);
}
}
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Module bootstrapping is handled by individual module providers
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;
use App\Models\Page;
class ViewServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
View::composer('*', function ($view) {
$coreSlugs = ['index', 'about', 'servicos', 'assistentes-digitais-com-ia', 'linux-servers', 'erp', 'fluxo-de-caixa-pro', 'produtos', 'downloads', 'faq', 'contato', 'politica-privacidade'];
$activePagesSlugs = Page::where('active', true)->pluck('slug')->toArray();
// Filter out module-specific pages when modules are disabled
if (!module_enabled('products')) {
$activePagesSlugs = array_diff($activePagesSlugs, ['produtos']);
}
if (!module_enabled('services')) {
$activePagesSlugs = array_diff($activePagesSlugs, ['servicos']);
}
$customPages = Page::where('active', true)
->whereNotIn('slug', $coreSlugs)
->get(['title', 'slug']);
$activeGalleries = \App\Models\Gallery::where('active', true)->get(['title', 'slug', 'category_id']);
$galleryCategories = [];
$activeGalleryCategories = \App\Models\GalleryCategory::where('active', true)->orderBy('name')->get();
foreach ($activeGalleryCategories as $category) {
$galleries = \App\Models\Gallery::where('active', true)
->where('category_id', $category->id)
->orderBy('title')
->get(['title', 'slug']);
if ($galleries->count() > 0) {
$galleryCategories[$category->name] = $galleries;
}
}
// Global Company Data
$company = [
'name' => \App\Models\Setting::get('company_name', 'Syspanel'),
'cnpj' => \App\Models\Setting::get('company_cnpj'),
'email' => \App\Models\Setting::get('company_email'),
'phone' => \App\Models\Setting::get('company_phone', '(11) 99999-9999'),
'address' => \App\Models\Setting::get('company_address', 'Endereço não configurado'),
'hours' => \App\Models\Setting::get('company_hours'),
'instagram' => \App\Models\Setting::get('company_instagram'),
'facebook' => \App\Models\Setting::get('company_facebook'),
'linkedin' => \App\Models\Setting::get('company_linkedin'),
];
$view->with([
'activePages' => $activePagesSlugs,
'customPages' => $customPages,
'activeGalleries' => $activeGalleries,
'galleryCategories' => $galleryCategories,
'activeGalleryCategories' => $activeGalleryCategories,
'company' => (object)$company,
'whatsapp_enabled' => $this->checkWhatsappAvailability()
]);
});
}
/**
* Check if the WhatsApp button should be visible based on schedule.
*/
private function checkWhatsappAvailability(): bool
{
if (\App\Models\Setting::get('whatsapp_floating_button') !== 'on') {
return false;
}
$now = now();
$currentTime = $now->format('H:i');
$startTime = \App\Models\Setting::get('whatsapp_start_time', '08:00');
$endTime = \App\Models\Setting::get('whatsapp_end_time', '18:00');
$lunchStart = \App\Models\Setting::get('whatsapp_lunch_start');
$lunchEnd = \App\Models\Setting::get('whatsapp_lunch_end');
// Outside business hours
if ($currentTime < $startTime || $currentTime > $endTime) {
return false;
}
// If lunch break is configured
if ($lunchStart && $lunchEnd) {
if ($currentTime >= $lunchStart && $currentTime <= $lunchEnd) {
return false;
}
}
return true;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Config;
/**
* Module Manager
*
* Centralized service for checking module availability.
* Modules are configured in config/modules.php and can be
* enabled/disabled via .env variables.
*/
class ModuleManager
{
/**
* Check if a module is enabled.
*
* @param string $module The module identifier (e.g., 'products', 'services')
* @return bool
*/
public function isEnabled(string $module): bool
{
$modules = Config::get('modules', []);
if (!isset($modules[$module]['enabled'])) {
return false;
}
$enabled = $modules[$module]['enabled'];
// Handle both boolean and string values from env/config
return filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
}
/**
* Get all enabled modules.
*
* @return array
*/
public function getEnabledModules(): array
{
$modules = Config::get('modules', []);
return array_filter($modules, function ($config) {
return filter_var($config['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
});
}
/**
* Get all registered modules (enabled or disabled).
*
* @return array
*/
public function getAllModules(): array
{
return Config::get('modules', []);
}
/**
* Get the list of enabled module identifiers.
*
* @return array<string>
*/
public function getEnabledModuleNames(): array
{
return array_keys($this->getEnabledModules());
}
}

Some files were not shown because too many files have changed in this diff Show More