diff --git a/.env.local-example b/.env.local-example index 789b5f1663ac09c686c9496474a848dfbfab53a1..004ebc7adb39a9800ad2126fb1989674b8ce6005 100644 --- a/.env.local-example +++ b/.env.local-example @@ -82,4 +82,8 @@ CDN_FILESYSTEM_DISK=bunnycdn AI_PROVIDER=openai OPENAI_URL=https://api.openai.com/v1/chat/completions OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://ip-api.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 \ No newline at end of file diff --git a/.env.production b/.env.production index 4306866dbd91a2ba10c3c8c77423bafcc8dbce5b..31ad2117f53d2b40e76814c23386cfdab406ec38 100644 --- a/.env.production +++ b/.env.production @@ -82,4 +82,10 @@ CDN_FILESYSTEM_DISK=bunnycdn AI_PROVIDER=openai OPENAI_URL=https://api.openai.com/v1/chat/completions OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://ip-api.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 + +APP_PRIVATE_MODE_SECRET=secret \ No newline at end of file diff --git a/.env.testing b/.env.testing index 27f3f5951fe626efb7e765dee1da5748abae4a5d..7e40aa8641d219fb6f21e09ad7e1760f43b7c0fe 100644 --- a/.env.testing +++ b/.env.testing @@ -78,4 +78,10 @@ BUNNYCDN_REGION=de AI_PROVIDER=openai OPENAI_URL=https://api.test-provider.com/v1/chat/completions OPENAI_API_KEY=fake-api-key -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://api.test-provider.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 + +APP_PRIVATE_MODE_SECRET=secret \ No newline at end of file diff --git a/README.md b/README.md index 3baed05eaefdb5e364186c5bead5128a60991f39..f60d50dcaa41906164541649d954ffbeb58c9f55 100644 --- a/README.md +++ b/README.md @@ -62,25 +62,6 @@ #### Laravel Idea #### Configuration -##### Configuration des projets multiples - -Comme le projet est dépendant de quelques paquets développés en interne, il est nécessaire de configurer PHPStorm pour -vous faciliter leur développement en simultané. - -1. Ouvrez les préférences de PHPStorm (Ctrl + Alt + S) -2. Allez dans `Version Control` -3. Allez dans `Directory Mappings` -4. Cliquez sur `+` puis `Directory` -5. Sélectionnez le dossier du projet sous-jacent -6. Cliquez sur `OK` - - - -Vous devriez voir apparaître les multiples projets Git dans l'onglet vcs de PHPStorm. À présent, vous devriez pouvoir -créer des branches sur les différents projets simultanément. - - - ##### Interpréteur PHP Il est nécessaire de renseigner le container Docker comme interpréteur PHP. Pour cela, suivez les étapes suivantes : diff --git a/app/Console/Commands/ProcessIpAdressesCommand.php b/app/Console/Commands/ProcessIpAdressesCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..c9f8b9e07e5ca2c41dfaa11a1386b3115a4f2d3e --- /dev/null +++ b/app/Console/Commands/ProcessIpAdressesCommand.php @@ -0,0 +1,31 @@ +<?php + +namespace App\Console\Commands; + +use App\Jobs\ProcessIpAddressesJob; +use Illuminate\Console\Command; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class ProcessIpAdressesCommand extends Command +{ + protected $signature = 'process:ip-adresses'; + + protected $description = 'Process ip adresses to resolve their location'; + + public function handle(): void + { + $ipAddresses = IpAddress::leftJoin('ip_address_metadata', 'ip_addresses.id', '=', 'ip_address_metadata.ip_address_id') + ->whereNull('ip_address_metadata.id') + ->select('ip_addresses.id', 'ip_addresses.ip') + ->get(); + + if ($ipAddresses->isEmpty()) { + $this->info('No ip adresses to process'); + + return; + } + + $this->info("Processing {$ipAddresses->count()} ip adresses"); + ProcessIpAddressesJob::dispatch($ipAddresses); + } +} diff --git a/app/Http/Controllers/Admin/CreationController.php b/app/Http/Controllers/Admin/CreationController.php index 93f4590310abaaf1fb1c4b4a49123722506eaa34..792b41cf0547811b75577778e475c6e0b8d6b052 100644 --- a/app/Http/Controllers/Admin/CreationController.php +++ b/app/Http/Controllers/Admin/CreationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Jobs\TranslateCreationJob; use App\Models\Category; use App\Models\Creation; use App\Models\Translation; @@ -350,4 +351,15 @@ public function removeAdditionalImage(int $creationId, int $uploadedPictureId): 'success' => true, ]); } + + public function translateWithAi(int $creationId): JsonResponse + { + $creation = Creation::findOrFail($creationId); + $job = new TranslateCreationJob($creation); + dispatch($job); + + return response()->json([ + 'success' => true, + ]); + } } diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index eca59cfbc44ca3fb6f686ab7948e98eb8745976a..6c119307d9c3c4fd5631c82e04315cb4acaa8e4f 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use Carbon\Carbon; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use Illuminate\View\View; @@ -11,29 +12,43 @@ class HomeController extends Controller { - public function index(): View + public function index(Request $request): View { - $publicRoutes = collect(Route::getRoutes()->getRoutes()) - ->filter(fn ($route) => ! Str::startsWith($route->uri, ['admin', 'api']) && ! empty($route->uri) && $route->uri !== '/') - ->pluck('uri'); - - $visits = LoggedRequest::selectRaw(' - logged_requests.url_id, - logged_requests.ip_address_id, - logged_requests.country_code, - logged_requests.created_at, - urls.url - ') + $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date', + ]); + + $routes = Route::getRoutes()->getRoutes(); + + $individualExcludedRoutes = []; + foreach ($routes as $route) { + if (Str::startsWith($route->uri, ['login', 'register', 'password', 'admin', 'forgot-password'])) { + $individualExcludedRoutes[] = config('app.url').'/'.$route->uri; + } + } + + $visits = LoggedRequest::select([ + 'logged_requests.url_id', + 'logged_requests.referer_url_id', + 'logged_requests.origin_url_id', + 'logged_requests.ip_address_id', + 'ip_address_metadata.country_code', + 'logged_requests.created_at', + 'urls.url', + 'referer_url.url as referer_url', + 'origin_url.url as origin_url']) ->distinct('logged_requests.url_id', 'logged_requests.ip_address_id') ->join('urls', 'logged_requests.url_id', '=', 'urls.id') + ->leftJoin('urls as referer_url', 'logged_requests.referer_url_id', '=', 'referer_url.id') + ->leftJoin('urls as origin_url', 'logged_requests.origin_url_id', '=', 'origin_url.id') ->join('user_agent_metadata', 'logged_requests.user_agent_id', '=', 'user_agent_metadata.user_agent_id') - ->where(function ($query) use ($publicRoutes) { - foreach ($publicRoutes as $uri) { - $query->orWhere('urls.url', 'like', "%$uri%"); - } - }) + ->join('ip_address_metadata', 'logged_requests.ip_address_id', '=', 'ip_address_metadata.ip_address_id') + ->whereLike('urls.url', config('app.url').'%') + ->whereNotIn('urls.url', $individualExcludedRoutes) ->where('user_agent_metadata.is_bot', false) ->where('status_code', 200) + ->whereNull('logged_requests.user_id') ->get(); $now = now(); @@ -42,21 +57,54 @@ public function index(): View $totalVisitsPastThirtyDays = $visits->where('created_at', '>=', $now->copy()->subDays(30))->count(); $totalVisitsAllTime = $visits->count(); - $visitsPerDay = $visits->groupBy(fn ($visit) => Carbon::parse($visit->created_at)->format('Y-m-d')) + $periods = [ + now()->format('Y-m-d') => 'Aujourd\'hui', + now()->subDay()->format('Y-m-d') => 'Hier', + now()->subDays(7)->format('Y-m-d') => 'Les 7 derniers jours', + now()->subDays(30)->format('Y-m-d') => 'Les 30 derniers jours', + now()->startOfMonth()->format('Y-m-d') => 'Ce mois-ci', + now()->subMonth()->startOfMonth()->format('Y-m-d') => 'Le mois dernier', + ]; + + if ($visits->isNotEmpty()) { + $periods[$visits->min('created_at')->format('Y-m-d')] = 'Depuis le début'; + } + + $startDate = $request->input('start_date', now()->subDays(30)->format('Y-m-d')); + $dateEnd = $request->input('end_date', now()->format('Y-m-d')); + + $selectedPeriod = $startDate; + + // Now, all the stats are calculated for the selected period + $visitsPerDay = $visits->where('created_at', '>=', $startDate) + ->where('created_at', '<=', $dateEnd) + ->groupBy(fn ($visit) => Carbon::parse($visit->created_at)->format('Y-m-d')) ->map(fn ($group) => ['date' => $group->first()->created_at->format('Y-m-d'), 'count' => $group->count()]) ->values(); - $visitsByCountry = $visits->groupBy('country_code') + $visitsByCountry = $visits->where('created_at', '>=', $startDate) + ->where('created_at', '<=', $dateEnd) + ->groupBy('country_code') ->map(fn ($group, $country) => ['country_code' => $country, 'count' => $group->count()]) ->values(); - $mostVisitedPages = $visits->groupBy('url') + $mostVisitedPages = $visits->where('created_at', '>=', $startDate) + ->where('created_at', '<=', $dateEnd) + ->groupBy('url') ->map(fn ($group, $url) => ['url' => $url, 'count' => $group->count()]) ->sortByDesc('count') ->values(); - $mostVisitedPagesForPastTwentyFourHours = $visits->where('created_at', '>=', $now->copy()->subDay()) - ->groupBy('url') + $bestsReferrers = $visits->where('created_at', '>=', $startDate) + ->where('created_at', '<=', $dateEnd) + ->groupBy('referer_url') + ->map(fn ($group, $url) => ['url' => $url, 'count' => $group->count()]) + ->sortByDesc('count') + ->values(); + + $bestOrigins = $visits->where('created_at', '>=', $startDate) + ->where('created_at', '<=', $dateEnd) + ->groupBy('origin_url') ->map(fn ($group, $url) => ['url' => $url, 'count' => $group->count()]) ->sortByDesc('count') ->values(); @@ -69,7 +117,10 @@ public function index(): View 'visitsPerDay' => $visitsPerDay, 'visitsByCountry' => $visitsByCountry, 'mostVisitedPages' => $mostVisitedPages, - 'mostVisitedPagesForPastTwentyFourHours' => $mostVisitedPagesForPastTwentyFourHours, + 'bestsReferrers' => $bestsReferrers, + 'bestOrigins' => $bestOrigins, + 'periods' => $periods, + 'selectedPeriod' => $selectedPeriod, ]); } } diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 8829ecf271ec3f55a2789d4bf3bec3ac54f256e0..557a1f8d4590a8bf0bec8a82fd27604fbb49a71f 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Mail\AdminInvitationMail; +use App\Models\DbConfig; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -22,16 +23,30 @@ class SettingsController extends Controller 'label' => 'Général', 'icon' => 'circle-user', ], - [ - 'route' => 'admin.settings.users', - 'label' => 'Utilisateurs', - 'icon' => 'users', - ], ]; + protected bool $isSuperAdmin; + + public function __construct() + { + $this->isSuperAdmin = Auth::user()->email === config('app.protected_account_email'); + + if ($this->isSuperAdmin) { + $this->headerRoutes[] = [ + 'route' => 'admin.settings.users', + 'label' => 'Utilisateurs', + 'icon' => 'users', + ]; + $this->headerRoutes[] = [ + 'route' => 'admin.settings.website-config', + 'label' => 'Configuration du site', + 'icon' => 'gear', + ]; + } + } + public function userListPage(): View { - $isSuperAdmin = auth()->user()->email === config('app.protected_account_email'); $users = User::all(); foreach ($users as $user) { @@ -41,7 +56,7 @@ public function userListPage(): View return view('admin.settings.users', [ 'headerRoutes' => $this->headerRoutes, 'users' => $users, - 'isSuperAdmin' => $isSuperAdmin, + 'isSuperAdmin' => $this->isSuperAdmin, ]); } @@ -52,9 +67,7 @@ public function createUser(Request $request): RedirectResponse 'email' => 'required|string|email|max:255|unique:users', ]); - $isSuperAdmin = Auth::user()->email === config('app.protected_account_email'); - - if (! $isSuperAdmin) { + if (! $this->isSuperAdmin) { return redirect()->route('admin.settings.users') ->with('error', 'Vous n\'avez pas les droits pour effectuer cette action.'); } @@ -73,9 +86,7 @@ public function createUser(Request $request): RedirectResponse public function deleteUser(int $id): RedirectResponse { - $isSuperAdmin = auth()->user()->email === config('app.protected_account_email'); - - if (! $isSuperAdmin) { + if (! $this->isSuperAdmin) { return redirect()->route('admin.settings.users') ->with('error', 'Vous n\'avez pas les droits pour effectuer cette action.'); } @@ -140,4 +151,39 @@ public function updateUserInfos(Request $request): RedirectResponse return redirect()->route('admin.settings.general')->with('success', 'Informations mises à jour.'); } + + public function websiteConfigPage(): RedirectResponse|View + { + if (! $this->isSuperAdmin) { + return redirect()->route('admin.settings.general') + ->with('error', 'Vous n\'avez pas les droits pour effectuer cette action.'); + } + + return view('admin.settings.website-config', [ + 'headerRoutes' => $this->headerRoutes, + ]); + } + + public function updateWebsiteConfig(Request $request): RedirectResponse + { + $request->validate([ + 'maintenance_mode' => 'sometimes|nullable|string|in:true,false', + 'website_name' => 'sometimes|nullable|string|max:255', + ]); + + if (! $this->isSuperAdmin) { + return redirect()->route('admin.settings.general') + ->with('error', 'Vous n\'avez pas les droits pour effectuer cette action.'); + } + + if ($request->has('maintenance_mode')) { + DbConfig::set('maintenance_mode', $request->maintenance_mode); + } + + if ($request->has('website_name')) { + DbConfig::set('website_name', $request->website_name); + } + + return redirect()->route('admin.settings.website-config')->with('success', 'Configuration mise à jour.'); + } } diff --git a/app/Http/Controllers/Public/LegalMentionsController.php b/app/Http/Controllers/Public/LegalMentionsController.php new file mode 100644 index 0000000000000000000000000000000000000000..4098697920ba33c78d7742a818a9902af53125af --- /dev/null +++ b/app/Http/Controllers/Public/LegalMentionsController.php @@ -0,0 +1,21 @@ +<?php + +namespace App\Http\Controllers\Public; + +use App\Http\Controllers\Controller; +use App\Models\LegalMention; + +class LegalMentionsController extends Controller +{ + public function __invoke() + { + $activeLegalMentionsText = LegalMention::where('active', true)->first(); + + if (! $activeLegalMentionsText) { + abort(404); + } + $activeLegalMentionsText = $activeLegalMentionsText->contentTransKey->getTranslation(); + + return view('public.legal-mentions', compact('activeLegalMentionsText')); + } +} diff --git a/app/Http/Controllers/Public/TermsController.php b/app/Http/Controllers/Public/TermsController.php new file mode 100644 index 0000000000000000000000000000000000000000..a20736b21e40f1970a758be6b82ebd599c7956b0 --- /dev/null +++ b/app/Http/Controllers/Public/TermsController.php @@ -0,0 +1,22 @@ +<?php + +namespace App\Http\Controllers\Public; + +use App\Http\Controllers\Controller; +use App\Models\TermsSection; +use Illuminate\View\View; + +class TermsController extends Controller +{ + public function __invoke(): View + { + $activeTermsText = TermsSection::where('active', true)->first(); + + if (! $activeTermsText) { + abort(404); + } + $activeTermsText = $activeTermsText->contentTranslationKey->getTranslation(); + + return view('public.terms', compact('activeTermsText')); + } +} diff --git a/app/Http/Middleware/CheckPrivateModeMiddleware.php b/app/Http/Middleware/CheckPrivateModeMiddleware.php index 2e9ba639dc3fd4fac8951d165f007a3aa89a07e7..ca09e19f96a6e7fee067ba578508706656cd8b68 100644 --- a/app/Http/Middleware/CheckPrivateModeMiddleware.php +++ b/app/Http/Middleware/CheckPrivateModeMiddleware.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\DbConfig; use Closure; use Illuminate\Http\Request; @@ -9,9 +10,20 @@ class CheckPrivateModeMiddleware { public function handle(Request $request, Closure $next) { - if (config('app.private_mode') && ! $request->is('maintenance') && ! auth()->check()) { - return redirect()->route('maintenance'); - } elseif (! config('app.private_mode') && $request->is('maintenance')) { + $privateModeEnabled = DbConfig::get('maintenance_mode', 'true'); + $privateModeSecret = config('app.private_mode_secret'); + $userSecretInput = $request->input('secret'); + $secretIsUsable = ! empty($privateModeSecret) && $privateModeSecret === $userSecretInput; + + if ($privateModeEnabled) { + if (! $secretIsUsable && ! auth()->check()) { + if (! $request->is('maintenance')) { + return redirect()->route('maintenance'); + } + } + } + + if (! $privateModeEnabled && $request->is('maintenance') && ! auth()->check()) { return redirect()->route('index'); } diff --git a/app/Jobs/ProcessIpAddressesJob.php b/app/Jobs/ProcessIpAddressesJob.php new file mode 100644 index 0000000000000000000000000000000000000000..d02f8cd33ecd23af984eb247bfdb10128ff18392 --- /dev/null +++ b/app/Jobs/ProcessIpAddressesJob.php @@ -0,0 +1,51 @@ +<?php + +namespace App\Jobs; + +use App\Models\IpAddressMetadata; +use App\Services\IpAddressMetadataResolverService; +use Exception; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; + +class ProcessIpAddressesJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct(private readonly Collection $ipAddresses) {} + + public function handle(IpAddressMetadataResolverService $ipAddressMetadataResolver): void + { + try { + $metadataObjects = $ipAddressMetadataResolver->resolve($this->ipAddresses); + } catch (Exception $exception) { + report($exception); + $this->fail($exception); + + return; + } + + foreach ($metadataObjects as $metadata) { + if ($metadata['status'] === 'fail') { + Log::warning('Failed to resolve IP address metadata.', [ + 'query' => $metadata['query'], + 'message' => $metadata['message'], + ]); + + continue; + } + + IpAddressMetadata::create([ + 'ip_address_id' => $this->ipAddresses->where('ip', $metadata['query'])->first()->id, + 'country_code' => $metadata['countryCode'], + 'lat' => $metadata['lat'], + 'lon' => $metadata['lon'], + ]); + } + } +} diff --git a/app/Jobs/TranslateCreationJob.php b/app/Jobs/TranslateCreationJob.php new file mode 100644 index 0000000000000000000000000000000000000000..152419f5cf4d53057709885073e673910ec8b018 --- /dev/null +++ b/app/Jobs/TranslateCreationJob.php @@ -0,0 +1,81 @@ +<?php + +namespace App\Jobs; + +use App\Models\Creation; +use App\Models\Translation; +use App\Services\AiProviderService; +use Exception; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; + +class TranslateCreationJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct(private readonly Creation $creation) {} + + public function handle(AiProviderService $aiProviderService): void + { + $frenchName = $this->creation->nameTranslationKey->getTranslation('fr'); + $frenchShortDesc = $this->creation->shortDescriptionTranslationKey->getTranslation('fr'); + $frenchDesc = $this->creation->descriptionTranslationKey->getTranslation('fr'); + + $shortDescriptionTranslationKeyId = $this->creation->shortDescriptionTranslationKey->id; + $descriptionTranslationKeyId = $this->creation->descriptionTranslationKey->id; + + if (! empty($frenchName)) { + Translation::updateOrCreate( + ['translation_key_id' => $this->creation->name_translation_key_id, 'locale' => 'en'], + ['text' => $frenchName] + ); + Cache::forget("translation_key_{$this->creation->name_translation_key_id}_en"); + } + + if (! empty($frenchShortDesc)) { + Translation::updateOrCreate( + ['translation_key_id' => $shortDescriptionTranslationKeyId, 'locale' => 'en'], + ['text' => $this->translate($frenchShortDesc, $aiProviderService)] + ); + Cache::forget("translation_key_{$shortDescriptionTranslationKeyId}_en"); + } + + if (! empty($frenchDesc)) { + Translation::updateOrCreate( + ['translation_key_id' => $descriptionTranslationKeyId, 'locale' => 'en'], + ['text' => $this->translate($frenchDesc, $aiProviderService)] + ); + Cache::forget("translation_key_{$descriptionTranslationKeyId}_en"); + } + } + + /** + * Translate the given text from French to English + * + * @param string $text The text to translate + * @param AiProviderService $aiProviderService The AI provider service + * @return string The translated text + */ + private function translate(string $text, AiProviderService $aiProviderService): string + { + try { + $result = $aiProviderService->prompt( + 'You are a helpful assistant that translates french markdown text in english and that outputs JSON in the format {message:string}. Markdown is supported.', + $text + ); + + return $result['message'] ?? ''; + } catch (Exception $e) { + Log::error("Failed to translate text: {$text}", [ + 'exception' => $e->getMessage(), + ]); + } + + return ''; + } +} diff --git a/app/Models/DbConfig.php b/app/Models/DbConfig.php new file mode 100644 index 0000000000000000000000000000000000000000..cddde72e647769d79782790b8869f4363605175e --- /dev/null +++ b/app/Models/DbConfig.php @@ -0,0 +1,97 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; + +class DbConfig extends Model +{ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'key', + 'value', + ]; + + /** + * Get a configuration value by its key. + * Value types casts: + * - 'true' => true + * - 'false' => false + * - integers => int + * - floats => float + * - strings => string + * + * @param string $key The key of the configuration value. Example: 'app.name' + * @param float|bool|int|string|null $default The default value to return if the configuration does not exist. + * @return float|bool|int|string|null The value of the configuration. + */ + public static function get(string $key, float|bool|int|string|null $default = null): float|bool|int|string|null + { + $config = Cache::rememberForever('config_'.$key, function () use ($key) { + return self::where('key', $key)->first(); + }); + + if (empty($config) || ! $config->exists || $config->value === null) { + return $default; + } + + // Cast to boolean if the value is a boolean + if ($config->value === 'true') { + return true; + } elseif ($config->value === 'false') { + return false; + } + + // Cast to integer if the value is an integer + if (is_numeric($config->value)) { + if (is_float($config->value)) { + return (float) $config->value; + } else { + return (int) $config->value; + } + } + + return $config->value; + } + + /** + * Set a configuration value by its key. + * + * @param string $key The key of the configuration value. Example: 'app.name' + * @param string|int|float|bool $value The value of the configuration. + */ + public static function set(string $key, float|bool|int|string|null $value): void + { + if (Cache::has('config_'.$key)) { + Cache::forget('config_'.$key); + } + + $config = self::where('key', $key)->first(); + + if ($value === null) { + $config?->delete(); + + return; + } + + if ($config === null) { + self::create([ + 'key' => $key, + 'value' => $value, + ]); + } else { + $config->update([ + 'value' => $value, + ]); + } + + Cache::rememberForever('config_'.$key, function () use ($key) { + return self::where('key', $key)->first(); + }); + } +} diff --git a/app/Models/IpAddressMetadata.php b/app/Models/IpAddressMetadata.php new file mode 100644 index 0000000000000000000000000000000000000000..6c0f61999c3b791d570c8b0ec353d43614b7c725 --- /dev/null +++ b/app/Models/IpAddressMetadata.php @@ -0,0 +1,29 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadata extends Model +{ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'ip_address_id', + 'country_code', + 'lat', + 'lon', + ]; + + const COUNTRY_CODES = ['AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HA', 'HB', 'HC', 'HD', 'HE', 'HF', 'HG', 'HH', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IA', 'IB', 'IC', 'ID', 'IE', 'IF', 'IG', 'IH', 'II', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JA', 'JB', 'JC', 'JD', 'JE', 'JF', 'JG', 'JH', 'JI', 'JJ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KA', 'KB', 'KC', 'KD', 'KE', 'KF', 'KG', 'KH', 'KI', 'KJ', 'KK', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LD', 'LE', 'LF', 'LG', 'LH', 'LI', 'LJ', 'LK', 'LL', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MA', 'MB', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MI', 'MJ', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NB', 'NC', 'ND', 'NE', 'NF', 'NG', 'NH', 'NI', 'NJ', 'NK', 'NL', 'NM', 'NN', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OA', 'OB', 'OC', 'OD', 'OE', 'OF', 'OG', 'OH', 'OI', 'OJ', 'OK', 'OL', 'OM', 'ON', 'OO', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PA', 'PB', 'PC', 'PD', 'PE', 'PF', 'PG', 'PH', 'PI', 'PJ', 'PK', 'PL', 'PM', 'PN', 'PO', 'PP', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', 'QH', 'QI', 'QJ', 'QK', 'QL', 'QM', 'QN', 'QO', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RA', 'RB', 'RC', 'RD', 'RE', 'RF', 'RG', 'RH', 'RI', 'RJ', 'RK', 'RL', 'RM', 'RN', 'RO', 'RP', 'RQ', 'RR', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'SA', 'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SQ', 'SR', 'SS', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TA', 'TB', 'TC', 'TD', 'TE', 'TF', 'TG', 'TH', 'TI', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TQ', 'TR', 'TS', 'TT', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UA', 'UB', 'UC', 'UD', 'UE', 'UF', 'UG', 'UH', 'UI', 'UJ', 'UK', 'UL', 'UM', 'UN', 'UO', 'UP', 'UQ', 'UR', 'US', 'UT', 'UU', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VA', 'VB', 'VC', 'VD', 'VE', 'VF', 'VG', 'VH', 'VI', 'VJ', 'VK', 'VL', 'VM', 'VN', 'VO', 'VP', 'VQ', 'VR', 'VS', 'VT', 'VU', 'VV', 'VW', 'VX', 'VY', 'VZ', 'WA', 'WB', 'WC', 'WD', 'WE', 'WF', 'WG', 'WH', 'WI', 'WJ', 'WK', 'WL', 'WM', 'WN', 'WO', 'WP', 'WQ', 'WR', 'WS', 'WT', 'WU', 'WV', 'WW', 'WX', 'WY', 'WZ', 'XA', 'XB', 'XC', 'XD', 'XE', 'XF', 'XG', 'XH', 'XI', 'XJ', 'XK', 'XL', 'XM', 'XN', 'XO', 'XP', 'XQ', 'XR', 'XS', 'XT', 'XU', 'XV', 'XW', 'XX', 'XY', 'XZ', 'YA', 'YB', 'YC', 'YD', 'YE', 'YF', 'YG', 'YH', 'YI', 'YJ', 'YK', 'YL', 'YM', 'YN', 'YO', 'YP', 'YQ', 'YR', 'YS', 'YT', 'YU', 'YV', 'YW', 'YX', 'YY', 'YZ', 'ZA', 'ZB', 'ZC', 'ZD', 'ZE', 'ZF', 'ZG', 'ZH', 'ZI', 'ZJ', 'ZK', 'ZL', 'ZM', 'ZN', 'ZO', 'ZP', 'ZQ', 'ZR', 'ZS', 'ZT', 'ZU', 'ZV', 'ZW', 'ZX', 'ZY', 'ZZ']; + + public function ipAddress(): BelongsTo + { + return $this->belongsTo(IpAddress::class); + } +} diff --git a/app/Services/AiProviderService.php b/app/Services/AiProviderService.php index 4b41c0262feaaba8b06882a50444b89fed2ccdf8..89800c6f85507dbdd688893115a0fc4e8b8d587a 100644 --- a/app/Services/AiProviderService.php +++ b/app/Services/AiProviderService.php @@ -16,6 +16,7 @@ class AiProviderService * * @param string $systemRole The system role to send to the AI provider. E.g. "You are a helpful assistant." * @param string $prompt The prompt to send to the AI provider + * @param UploadedPicture ...$pictures The pictures to send to the AI provider * @return array The response from the AI provider. */ public function promptWithPictures(string $systemRole, string $prompt, UploadedPicture ...$pictures): array @@ -85,6 +86,13 @@ public function promptWithPictures(string $systemRole, string $prompt, UploadedP return $this->callApi(config('ai-provider.providers.'.$selectedProvider.'.url'), $requestBody); } + /** + * Prompt the AI provider with a text + * + * @param string $systemRole The system role to send to the AI provider. E.g. "You are a helpful assistant." + * @param string $prompt The prompt to send to the AI provider + * @return array The response from the AI provider. + */ public function prompt(string $systemRole, string $prompt): array { $selectedProvider = config('ai-provider.selected-provider'); diff --git a/app/Services/IpAddressMetadataResolverService.php b/app/Services/IpAddressMetadataResolverService.php new file mode 100644 index 0000000000000000000000000000000000000000..13f4eaec3cc317a5d6536bbe8ace6df74f42457a --- /dev/null +++ b/app/Services/IpAddressMetadataResolverService.php @@ -0,0 +1,69 @@ +<?php + +namespace App\Services; + +use Exception; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadataResolverService +{ + /** + * Resolve metadata for the given IP addresses. + * This method has a fail tolerance. The only exception that is thrown is when the API server returns a 422 status code. + * Server errors are logged and the method returns without throwing an exception. + * + * @param Collection $ipAddresses The IP addresses to resolve metadata for. Instances of IpAddress. + * @return array{array{status: 'success'|'fail', message?: string, countryCode?: string, lat?: float, lon?: float, query: string}} The resolved metadata for each IP address. + * + * @throws ConnectionException + */ + public static function resolve(Collection $ipAddresses): array + { + $url = config('ip-address-resolver.url').'?fields=status,message,countryCode,lat,lon,query'; + $maxIpPerCall = config('ip-address-resolver.max_ip_addresses_per_call'); + $maxCallsPerMinute = config('ip-address-resolver.call_limit_per_minute'); + $currentCallsCount = Cache::get('ip-address-resolver.calls_count', 0); + + if ($currentCallsCount >= $maxCallsPerMinute) { + Log::info('Max calls per minute reached. Skipping metadata resolution.'); + + return []; + } + + Cache::increment('ip-address-resolver.calls_count', 1, 60); + + if ($ipAddresses->count() > $maxIpPerCall) { + $ipAddresses = $ipAddresses->take($maxIpPerCall); + } + + $response = Http::post($url, $ipAddresses->pluck('ip')->toArray()); + + if ($response->failed()) { + $returnedError = [ + 'status' => $response->status(), + 'message' => $response->body(), + ]; + + if ($response->unprocessableContent()) { + $apiResponse = $response->json(); + Log::error('The API rejected the request with a 422 unprocessable entity status code. ', $returnedError); + throw new Exception('The API rejected the request with a 422 unprocessable entity status code. '.$apiResponse['message']); + } + if ($response->serverError()) { + Log::info('The API server encountered an error while processing the request. Skipping metadata resolution.', $returnedError); + + return []; + } + Log::error('The API server returned an unexpected status code. Skipping metadata resolution.', $returnedError); + + return []; + } + + return $response->json(); + } +} diff --git a/composer.lock b/composer.lock index fd9aca4cb1362286b38a0ca260169fbfbb84ce7d..31c03844cc31ebc1a50c1657014fe21bba8ec132 100644 --- a/composer.lock +++ b/composer.lock @@ -1680,16 +1680,16 @@ }, { "name": "laravel/framework", - "version": "v11.44.0", + "version": "v11.43.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e9a33da34815ac1ed46c7e4c477a775f4592f0a7" + "reference": "053f26afb699c845945e7380b407dd019a0a2c74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e9a33da34815ac1ed46c7e4c477a775f4592f0a7", - "reference": "e9a33da34815ac1ed46c7e4c477a775f4592f0a7", + "url": "https://api.github.com/repos/laravel/framework/zipball/053f26afb699c845945e7380b407dd019a0a2c74", + "reference": "053f26afb699c845945e7380b407dd019a0a2c74", "shasum": "" }, "require": { @@ -1891,7 +1891,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-02-24T13:08:54+00:00" + "time": "2025-02-19T16:06:03+00:00" }, { "name": "laravel/horizon", @@ -1975,21 +1975,21 @@ }, { "name": "laravel/octane", - "version": "v2.8.1", + "version": "v2.8.0", "source": { "type": "git", "url": "https://github.com/laravel/octane.git", - "reference": "951023e3c0ee8934d9f8338fd6495a0a969eefc4" + "reference": "1c5190cc5ad67eb4aadbf1816dcbfedc692851e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/octane/zipball/951023e3c0ee8934d9f8338fd6495a0a969eefc4", - "reference": "951023e3c0ee8934d9f8338fd6495a0a969eefc4", + "url": "https://api.github.com/repos/laravel/octane/zipball/1c5190cc5ad67eb4aadbf1816dcbfedc692851e7", + "reference": "1c5190cc5ad67eb4aadbf1816dcbfedc692851e7", "shasum": "" }, "require": { "laminas/laminas-diactoros": "^3.0", - "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/framework": "^10.10.1|^11.0", "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", "nesbot/carbon": "^2.66.0|^3.0", @@ -2010,9 +2010,9 @@ "livewire/livewire": "^2.12.3|^3.0", "mockery/mockery": "^1.5.1", "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", - "orchestra/testbench": "^8.21|^9.0|^10.0", + "orchestra/testbench": "^8.21|^9.0", "phpstan/phpstan": "^1.10.15", - "phpunit/phpunit": "^10.4|^11.5", + "phpunit/phpunit": "^10.4", "spiral/roadrunner-cli": "^2.6.0", "spiral/roadrunner-http": "^3.3.0" }, @@ -2061,7 +2061,7 @@ "issues": "https://github.com/laravel/octane/issues", "source": "https://github.com/laravel/octane" }, - "time": "2025-02-19T20:28:29+00:00" + "time": "2025-02-18T15:18:13+00:00" }, { "name": "laravel/prompts", @@ -2905,16 +2905,16 @@ }, { "name": "nesbot/carbon", - "version": "3.8.6", + "version": "3.8.5", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd" + "reference": "b1a53a27898639579a67de42e8ced5d5386aa9a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/b1a53a27898639579a67de42e8ced5d5386aa9a4", + "reference": "b1a53a27898639579a67de42e8ced5d5386aa9a4", "shasum": "" }, "require": { @@ -3007,7 +3007,7 @@ "type": "tidelift" } ], - "time": "2025-02-20T17:33:38+00:00" + "time": "2025-02-11T16:28:45+00:00" }, { "name": "nette/schema", @@ -4871,37 +4871,37 @@ }, { "name": "spatie/laravel-markdown", - "version": "2.7.1", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-markdown.git", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" + "reference": "4ca6107d095c2f857c860462cfbe401e0c15fe0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/4ca6107d095c2f857c860462cfbe401e0c15fe0f", + "reference": "4ca6107d095c2f857c860462cfbe401e0c15fe0f", "shasum": "" }, "require": { - "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", - "illuminate/view": "^9.0|^10.0|^11.0|^12.0", + "illuminate/cache": "^9.0|^10.0|^11.0", + "illuminate/contracts": "^9.0|^10.0|^11.0", + "illuminate/support": "^9.0|^10.0|^11.0", + "illuminate/view": "^9.0|^10.0|^11.0", "league/commonmark": "^2.6.0", "php": "^8.1", "spatie/commonmark-shiki-highlighter": "^2.5", "spatie/laravel-package-tools": "^1.4.3" }, "require-dev": { - "brianium/paratest": "^6.2|^7.8", - "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0", - "pestphp/pest": "^1.22|^2.0|^3.7", - "phpunit/phpunit": "^9.3|^11.5.3", + "brianium/paratest": "^6.2", + "nunomaduro/collision": "^5.3|^6.0", + "orchestra/testbench": "^6.15|^7.0|^8.0", + "pestphp/pest": "^1.22", + "phpunit/phpunit": "^9.3", "spatie/laravel-ray": "^1.23", - "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", - "vimeo/psalm": "^4.8|^6.7" + "spatie/pest-plugin-snapshots": "^1.1", + "vimeo/psalm": "^4.8" }, "type": "library", "extra": { @@ -4935,7 +4935,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + "source": "https://github.com/spatie/laravel-markdown/tree/2.7.0" }, "funding": [ { @@ -4943,7 +4943,7 @@ "type": "github" } ], - "time": "2025-02-21T13:43:18+00:00" + "time": "2024-12-10T15:11:24+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5082,21 +5082,21 @@ }, { "name": "spatie/shiki-php", - "version": "2.3.2", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/spatie/shiki-php.git", - "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5" + "reference": "24b4dcc161f37144180edbef49557edb96c1dc2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5", - "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/24b4dcc161f37144180edbef49557edb96c1dc2d", + "reference": "24b4dcc161f37144180edbef49557edb96c1dc2d", "shasum": "" }, "require": { "ext-json": "*", - "php": "^8.0", + "php": "^7.4|^8.0", "symfony/process": "^5.4|^6.4|^7.1" }, "require-dev": { @@ -5135,7 +5135,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/shiki-php/tree/2.3.2" + "source": "https://github.com/spatie/shiki-php/tree/2.3.1" }, "funding": [ { @@ -5143,7 +5143,7 @@ "type": "github" } ], - "time": "2025-02-21T14:16:57+00:00" + "time": "2025-02-18T13:18:46+00:00" }, { "name": "spatie/temporary-directory", @@ -8766,16 +8766,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.11.0", + "version": "5.10.3", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "07044bc8c13abd542756c3fd34dc66a5d6dee8e4" + "reference": "5346664973d10cf1abff20837fb1183f3c11a055" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/07044bc8c13abd542756c3fd34dc66a5d6dee8e4", - "reference": "07044bc8c13abd542756c3fd34dc66a5d6dee8e4", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/5346664973d10cf1abff20837fb1183f3c11a055", + "reference": "5346664973d10cf1abff20837fb1183f3c11a055", "shasum": "" }, "require": { @@ -8790,11 +8790,9 @@ "phpbench/phpbench": "^1.1", "phpmyadmin/coding-standard": "^3.0", "phpmyadmin/motranslator": "^4.0 || ^5.0", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-deprecation-rules": "^1.2", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-strict-rules": "^1.6", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.9.12", + "phpstan/phpstan-phpunit": "^1.3.3", "phpunit/phpunit": "^8.5 || ^9.6", "psalm/plugin-phpunit": "^0.16.1", "vimeo/psalm": "^4.11", @@ -8851,7 +8849,7 @@ "type": "other" } ], - "time": "2025-02-22T20:00:59+00:00" + "time": "2025-01-19T04:14:02+00:00" }, { "name": "phpstan/phpstan", @@ -9236,16 +9234,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.9", + "version": "11.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c91c830e7108a81e5845aeb6ba8fe3c1a4351c0b" + "reference": "c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c91c830e7108a81e5845aeb6ba8fe3c1a4351c0b", - "reference": "c91c830e7108a81e5845aeb6ba8fe3c1a4351c0b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049", + "reference": "c9bd61aab12f0fc5e82ecfe621ff518a1d1f1049", "shasum": "" }, "require": { @@ -9255,7 +9253,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -9317,7 +9315,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.8" }, "funding": [ { @@ -9333,7 +9331,7 @@ "type": "tidelift" } ], - "time": "2025-02-21T06:08:50+00:00" + "time": "2025-02-18T06:26:59+00:00" }, { "name": "sebastian/cli-parser", @@ -10438,7 +10436,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -10447,6 +10445,6 @@ "ext-pdo": "*", "ext-zip": "*" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/config/app.php b/config/app.php index b44d19f7b53e72e0c3756ca32a0d6d6540b17a67..5044bc0983f9c6575abfb3deba3697f93ed9988d 100644 --- a/config/app.php +++ b/config/app.php @@ -127,7 +127,7 @@ 'protected_account_email' => env('PROTECTED_ACCOUNT_EMAIL', ''), - 'private_mode' => env('APP_PRIVATE_MODE', false), + // 'private_mode' => env('APP_PRIVATE_MODE', false), 'imagick' => [ 'max_width' => env('IMAGICK_MAX_WIDTH', 16000), // 16k @@ -136,4 +136,6 @@ ], 'cdn_disk' => env('CDN_FILESYSTEM_DISK'), + + 'private_mode_secret' => env('APP_PRIVATE_MODE_SECRET'), ]; diff --git a/config/ip-address-resolver.php b/config/ip-address-resolver.php new file mode 100644 index 0000000000000000000000000000000000000000..7dc71a84fda14b4dd12a32ae5f08c7452edd2b95 --- /dev/null +++ b/config/ip-address-resolver.php @@ -0,0 +1,7 @@ +<?php + +return [ + 'url' => env('IP_ADDRESS_RESOLVER_URL', 'http://ip-api.com/batch'), + 'call_limit_per_minute' => env('IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE', 15), + 'max_ip_addresses_per_call' => env('IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL', 100), +]; diff --git a/database/factories/DbConfigFactory.php b/database/factories/DbConfigFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..d1d7eae53fa035167c8d93a7e65764f08269fb1b --- /dev/null +++ b/database/factories/DbConfigFactory.php @@ -0,0 +1,19 @@ +<?php + +namespace Database\Factories; + +use App\Models\DbConfig; +use Illuminate\Database\Eloquent\Factories\Factory; + +class DbConfigFactory extends Factory +{ + protected $model = DbConfig::class; + + public function definition(): array + { + return [ + 'key' => $this->faker->word(), + 'value' => $this->faker->word(), + ]; + } +} diff --git a/database/factories/IpAddressMetadataFactory.php b/database/factories/IpAddressMetadataFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..5581b8193b0a4a4f0f6a2edf952e61f5bcf10303 --- /dev/null +++ b/database/factories/IpAddressMetadataFactory.php @@ -0,0 +1,23 @@ +<?php + +namespace Database\Factories; + +use App\Models\IpAddressMetadata; +use Illuminate\Database\Eloquent\Factories\Factory; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadataFactory extends Factory +{ + protected $model = IpAddressMetadata::class; + + public function definition(): array + { + return [ + 'country_code' => $this->faker->randomElement(IpAddressMetadata::COUNTRY_CODES), + 'lat' => $this->faker->latitude(), + 'lon' => $this->faker->longitude(), + + 'ip_address_id' => IpAddress::factory(), + ]; + } +} diff --git a/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php b/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php new file mode 100644 index 0000000000000000000000000000000000000000..387e34bb37c327ce10231111387850c065c3f420 --- /dev/null +++ b/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php @@ -0,0 +1,24 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('ip_address_metadata', function (Blueprint $table) { + $table->id(); + $table->foreignId('ip_address_id')->constrained('ip_addresses'); + $table->enum('country_code', ['AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HA', 'HB', 'HC', 'HD', 'HE', 'HF', 'HG', 'HH', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IA', 'IB', 'IC', 'ID', 'IE', 'IF', 'IG', 'IH', 'II', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JA', 'JB', 'JC', 'JD', 'JE', 'JF', 'JG', 'JH', 'JI', 'JJ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KA', 'KB', 'KC', 'KD', 'KE', 'KF', 'KG', 'KH', 'KI', 'KJ', 'KK', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LD', 'LE', 'LF', 'LG', 'LH', 'LI', 'LJ', 'LK', 'LL', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MA', 'MB', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MI', 'MJ', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NB', 'NC', 'ND', 'NE', 'NF', 'NG', 'NH', 'NI', 'NJ', 'NK', 'NL', 'NM', 'NN', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OA', 'OB', 'OC', 'OD', 'OE', 'OF', 'OG', 'OH', 'OI', 'OJ', 'OK', 'OL', 'OM', 'ON', 'OO', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PA', 'PB', 'PC', 'PD', 'PE', 'PF', 'PG', 'PH', 'PI', 'PJ', 'PK', 'PL', 'PM', 'PN', 'PO', 'PP', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', 'QH', 'QI', 'QJ', 'QK', 'QL', 'QM', 'QN', 'QO', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RA', 'RB', 'RC', 'RD', 'RE', 'RF', 'RG', 'RH', 'RI', 'RJ', 'RK', 'RL', 'RM', 'RN', 'RO', 'RP', 'RQ', 'RR', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'SA', 'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SQ', 'SR', 'SS', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TA', 'TB', 'TC', 'TD', 'TE', 'TF', 'TG', 'TH', 'TI', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TQ', 'TR', 'TS', 'TT', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UA', 'UB', 'UC', 'UD', 'UE', 'UF', 'UG', 'UH', 'UI', 'UJ', 'UK', 'UL', 'UM', 'UN', 'UO', 'UP', 'UQ', 'UR', 'US', 'UT', 'UU', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VA', 'VB', 'VC', 'VD', 'VE', 'VF', 'VG', 'VH', 'VI', 'VJ', 'VK', 'VL', 'VM', 'VN', 'VO', 'VP', 'VQ', 'VR', 'VS', 'VT', 'VU', 'VV', 'VW', 'VX', 'VY', 'VZ', 'WA', 'WB', 'WC', 'WD', 'WE', 'WF', 'WG', 'WH', 'WI', 'WJ', 'WK', 'WL', 'WM', 'WN', 'WO', 'WP', 'WQ', 'WR', 'WS', 'WT', 'WU', 'WV', 'WW', 'WX', 'WY', 'WZ', 'XA', 'XB', 'XC', 'XD', 'XE', 'XF', 'XG', 'XH', 'XI', 'XJ', 'XK', 'XL', 'XM', 'XN', 'XO', 'XP', 'XQ', 'XR', 'XS', 'XT', 'XU', 'XV', 'XW', 'XX', 'XY', 'XZ', 'YA', 'YB', 'YC', 'YD', 'YE', 'YF', 'YG', 'YH', 'YI', 'YJ', 'YK', 'YL', 'YM', 'YN', 'YO', 'YP', 'YQ', 'YR', 'YS', 'YT', 'YU', 'YV', 'YW', 'YX', 'YY', 'YZ', 'ZA', 'ZB', 'ZC', 'ZD', 'ZE', 'ZF', 'ZG', 'ZH', 'ZI', 'ZJ', 'ZK', 'ZL', 'ZM', 'ZN', 'ZO', 'ZP', 'ZQ', 'ZR', 'ZS', 'ZT', 'ZU', 'ZV', 'ZW', 'ZX', 'ZY', 'ZZ']); + $table->float('lat')->nullable(); + $table->float('lon')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ip_address_metadata'); + } +}; diff --git a/database/migrations/2025_02_23_145044_create_db_configs_table.php b/database/migrations/2025_02_23_145044_create_db_configs_table.php new file mode 100644 index 0000000000000000000000000000000000000000..09506350a1f0e6c5e8025e55bdc84ad74a00902f --- /dev/null +++ b/database/migrations/2025_02_23_145044_create_db_configs_table.php @@ -0,0 +1,22 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('db_configs', function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->string('value'); + }); + } + + public function down(): void + { + Schema::dropIfExists('db_configs'); + } +}; diff --git a/lang/en/contact.php b/lang/en/contact.php new file mode 100644 index 0000000000000000000000000000000000000000..87f2cd0a0374c6acae9a3c71e5e5fcb4dfb61cfe --- /dev/null +++ b/lang/en/contact.php @@ -0,0 +1,6 @@ +<?php + +return [ + 'desc' => 'Do you have a project in mind or want to discuss your graphic design needs? Feel free to contact me! Whether it’s for a collaboration, a quote request, or just to exchange creative ideas, I’m here to help.', + 'contact_me_on_one_of_my_social_media' => 'Contact me on one of my social media', +]; diff --git a/lang/en/footer.php b/lang/en/footer.php new file mode 100644 index 0000000000000000000000000000000000000000..8c46e83400e9f00b7abead2b34a3dd71c39d197e --- /dev/null +++ b/lang/en/footer.php @@ -0,0 +1,14 @@ +<?php + +return [ + 'contact' => [ + 'title' => 'Excited to work with you!', + 'desc' => 'Feel free to contact me if you need information, or if you just want to chat with me.', + ], + 'links' => [ + 'title' => 'Stay in touch', + ], + 'legals-infos' => [ + 'title' => 'Legal information', + ], +]; diff --git a/lang/en/generic.php b/lang/en/generic.php index 51de6fe9e666f20e6169dff894b319ce4b1416d9..a345a842c4e75dcc9747427837b12f5f10eb10f9 100644 --- a/lang/en/generic.php +++ b/lang/en/generic.php @@ -4,4 +4,6 @@ 'custom_needs' => 'A specific need?', 'custom_needs_desc' => 'I am open to any proposal. Don’t hesitate to contact me!', 'completed_in' => 'Completed in', + 'legal_mentions' => 'Legal notices', + 'terms' => 'Terms and conditions of sale', ]; diff --git a/lang/en/home.php b/lang/en/home.php index 16cf7a51a14278df222b272557969e29d607dc6e..93efc9e769e02189d61ea81d70d4486e327e8d6d 100644 --- a/lang/en/home.php +++ b/lang/en/home.php @@ -17,4 +17,5 @@ 'waving_hand' => 'Waving hand emoji', 'roxannas_photo' => 'Photo of Roxanna Valtre', ], + 'meta_description' => 'Roxanna Valtre, Graphic Designer. Discover my services and my portfolio.', ]; diff --git a/lang/fr/contact.php b/lang/fr/contact.php new file mode 100644 index 0000000000000000000000000000000000000000..67270fd422a5f39c002970a9ba9f6118848a3327 --- /dev/null +++ b/lang/fr/contact.php @@ -0,0 +1,6 @@ +<?php + +return [ + 'desc' => 'Vous avez un projet en tête ou souhaitez discuter de vos besoins en design graphique ? N\'hésitez pas à me contacter ! Que ce soit pour une collaboration, une demande de devis ou simplement pour échanger des idées créatives, je suis là pour vous aider.', + 'contact_me_on_one_of_my_social_media' => 'Contactez moi sur l\'un de mes réseaux', +]; diff --git a/lang/fr/footer.php b/lang/fr/footer.php new file mode 100644 index 0000000000000000000000000000000000000000..0e7acdedfc7c68d0ae44642d3a24acba707dda0b --- /dev/null +++ b/lang/fr/footer.php @@ -0,0 +1,14 @@ +<?php + +return [ + 'contact' => [ + 'title' => 'Hâte de travailler avec vous !', + 'desc' => 'N’hésitez pas à me contacter si vous avez besoin de renseignements, ou que vous souhaitez simplement discuter avec moi.', + ], + 'links' => [ + 'title' => 'Restons en contact', + ], + 'legals-infos' => [ + 'title' => 'Informations légales', + ], +]; diff --git a/lang/fr/generic.php b/lang/fr/generic.php index 4b27032c33a73fb3c47194d9a0b8197c2d786f5d..0c369358497101f7f0bfffd75983290fdc6b7610 100644 --- a/lang/fr/generic.php +++ b/lang/fr/generic.php @@ -4,4 +4,6 @@ 'custom_needs' => 'Un besoin particulier ?', 'custom_needs_desc' => 'Je suis ouverte à toute proposition. N’hésitez pas à me contacter !', 'completed_in' => 'Réalisé en', + 'legal_mentions' => 'Mentions légales', + 'terms' => 'Conditions générales de vente', ]; diff --git a/lang/fr/home.php b/lang/fr/home.php index c873aff786363813b37944ba5b8b5a907b465aef..b24c61701f0f400b4156847f875a2809446a9418 100644 --- a/lang/fr/home.php +++ b/lang/fr/home.php @@ -17,4 +17,5 @@ 'waving_hand' => 'Emoji de main qui salue', 'roxannas_photo' => 'Photo de Roxana Valtre', ], + 'meta_description' => 'Roxanna Valtre, Designer Graphique. Découvrez mes services et mon portfolio.', ]; diff --git a/resources/js/pages/admin/home.js b/resources/js/pages/admin/home.js index f6c7de90dd8ad0f688bcb885d1d3121f481aa37b..b431283491da6011be352ee4a65d5003cdadb9da 100644 --- a/resources/js/pages/admin/home.js +++ b/resources/js/pages/admin/home.js @@ -7,4 +7,12 @@ const app = createApp(); app.component('visits-per-day-chart', VisitsPerDayChart); app.component('visits-by-country-chart', VisitsByCountryChart); -app.mount('#admin-home') \ No newline at end of file +app.mount('#admin-home') + +const statsSelect = document.getElementById('statsSelect'); +statsSelect.addEventListener('change', () => { + let startDate = statsSelect.value; + let endDate = new Date().toISOString().split('T')[0]; + + window.location.href = `?start_date=${startDate}&end_date=${endDate}`; +}); \ No newline at end of file diff --git a/resources/scss/public.scss b/resources/scss/public.scss index 2d88a1389a6b99742d97199479c04e37fe0dbbda..6a7d3c8e7010d4a3502bf9c1c9fee0a8bd89d6db 100644 --- a/resources/scss/public.scss +++ b/resources/scss/public.scss @@ -3,6 +3,10 @@ @tailwind utilities; @import "fonts"; +h1, h2, h3, h4, h5, h6 { + font-family: "Sniglet", serif; +} + .markdown { * { @apply mb-4; diff --git a/resources/views/admin/creations/index.blade.php b/resources/views/admin/creations/index.blade.php index e13f5b4f9fcb48b70d6605515bbc45addf9ca8a3..c5e81d17f85c53b0cce028eff3327f3618c4f197 100644 --- a/resources/views/admin/creations/index.blade.php +++ b/resources/views/admin/creations/index.blade.php @@ -28,6 +28,11 @@ size="sm" tag="a" variant="danger"> Supprimer </x-bs.button> + <x-bs.button size="sm" variant="secondary" id="translate-{{ $creation->id }}" + onclick="translateWithAi({{ $creation->id }})"> + {{ \Spatie\Emoji\Emoji::flagsForFlagUnitedKingdom() }} + Traduire + </x-bs.button> </x-slot:footer> </x-bs.card> @endforeach @@ -38,3 +43,24 @@ @endif </div> @endsection + +@pushonce('scripts') + <script type="text/javascript"> + function translateWithAi(creationId) { + const button = document.querySelector(`#translate-${creationId}`); + button.disabled = true; + fetch(`/admin/creations/${creationId}/translate-with-ai`) + .then(response => response.json()) + .then(data => { + if (data.success) { + button.classList.remove('btn-secondary'); + button.classList.add('btn-success'); + } else { + button.disabled = false; + button.classList.remove('btn-secondary'); + button.classList.add('btn-warning'); + } + }); + } + </script> +@endpushonce diff --git a/resources/views/admin/home.blade.php b/resources/views/admin/home.blade.php index 98401498253489b1e32efa808c516f97f767c48e..7ab23f545144b56ba4a8f99f5f316d60e7d80428 100644 --- a/resources/views/admin/home.blade.php +++ b/resources/views/admin/home.blade.php @@ -2,9 +2,17 @@ @section('content') <div class="container" id="admin-home"> + @if(\App\Models\DbConfig::get('maintenance_mode', 'true')) + <x-bs.alert type="warning" :dismissible="false" icon="info" title="Information" style="max-width: none;"> + Le site est masqué au public car le mode maintenance est activé.<br> + <a href="{{ route('admin.settings.update-website-config') }}" class="alert-link"> + Rendez-vous sur la page de configuration du site pour le désactiver. + </a> + </x-bs.alert> + @endif <h3 class="mb-4">Statistiques des Visites uniques</h3> - <div class="grid g-4 mb-4"> + <div class="grid mb-4"> <!-- Total des visites --> <div class="g-col-12 g-col-md-6 g-col-lg-3"> <div class="card text-center"> @@ -40,7 +48,14 @@ </div> </div> - <div class="row g-4 mb-4"> + <div class="grid mb-4"> + <div class="g-col-12 g-col-md-6 g-col-lg-3"> + <x-bs.select id="statsSelect" label="Période de statistiques" name="period" + :options="$periods" :selected="$selectedPeriod"/> + </div> + </div> + + <div class="row mb-4"> <!-- Visites par jour --> <div class="g-col-md-6 mb-4"> <div class="card"> @@ -72,7 +87,7 @@ <div class="g-col-12 mb-4"> <div class="card"> <div class="card-body"> - <h5 class="card-title">Pages les plus visitées (24H)</h5> + <h5 class="card-title">Pages les plus visitées</h5> <div class="table-responsive"> <table class="table table-striped align-middle"> <thead> @@ -83,7 +98,7 @@ </tr> </thead> <tbody> - @foreach ($mostVisitedPagesForPastTwentyFourHours as $index => $page) + @foreach ($mostVisitedPages as $index => $page) <tr> <th scope="row">{{ $index + 1 }}</th> <td>{{ $page['url'] }}</td> @@ -97,25 +112,57 @@ </div> </div> + <!-- Meilleurs referers --> + <div class="g-col-12 mb-4"> + <div class="card"> + <div class="card-body"> + <h5 class="card-title"> + Referers (sites référents) les plus fréquents + </h5> + <div class="table-responsive"> + <table class="table table-striped align-middle"> + <thead> + <tr> + <th scope="col">#</th> + <th scope="col">Referer</th> + <th scope="col">Nombre de visites</th> + </tr> + </thead> + <tbody> + @foreach ($bestsReferrers as $index => $referer) + <tr> + <th scope="row">{{ $index + 1 }}</th> + <td>{{ $referer['url'] }}</td> + <td>{{ $referer['count'] }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + </div> + </div> + </div> + + <!-- Meilleures origines --> <div class="g-col-12"> <div class="card"> <div class="card-body"> - <h5 class="card-title">Pages les plus visitées</h5> + <h5 class="card-title">Origines les plus fréquentes</h5> <div class="table-responsive"> <table class="table table-striped align-middle"> <thead> <tr> <th scope="col">#</th> - <th scope="col">Page</th> + <th scope="col">Origine</th> <th scope="col">Nombre de visites</th> </tr> </thead> <tbody> - @foreach ($mostVisitedPages as $index => $page) + @foreach ($bestOrigins as $index => $origin) <tr> <th scope="row">{{ $index + 1 }}</th> - <td>{{ $page['url'] }}</td> - <td>{{ $page['count'] }}</td> + <td>{{ $origin['url'] }}</td> + <td>{{ $origin['count'] }}</td> </tr> @endforeach </tbody> diff --git a/resources/views/admin/settings/general.blade.php b/resources/views/admin/settings/general.blade.php index ef891f725d4090d82f6aad10f774fe5fc8668061..6ea34cd993b44b73a28a9be5dd5f29bb6afbe364 100644 --- a/resources/views/admin/settings/general.blade.php +++ b/resources/views/admin/settings/general.blade.php @@ -3,10 +3,32 @@ @section('content') <div class="grid"> <div class="g-col-12 g-col-md-6"> + @if (!empty($errors) && $errors->any()) + <x-bs.alert type="danger" class="mt-3"> + <ul> + @foreach ($errors->all() as $error) + <li>{{ $error }}</li> + @endforeach + </ul> + </x-bs.alert> + @endif + + @if (session('success')) + <x-bs.alert type="success" class="mt-3"> + {{ session('success') }} + </x-bs.alert> + @endif + + @if (session('error')) + <x-bs.alert type="danger" class="mt-3"> + {{ session('error') }} + </x-bs.alert> + @endif + <h5>Information</h5> <p>Ne remplissez que les champs que vous souhaitez mettre à jour.</p> <form action="{{ route('admin.settings.update-user-infos') }}" - method="post"> + method="post"> @csrf @method('PUT') @@ -48,22 +70,6 @@ class="mb-3" <x-bs.button type="submit">Enregistrer</x-bs.button> </form> - - @if (!empty($errors) && $errors->any()) - <x-bs.alert type="danger" class="mt-3"> - <ul> - @foreach ($errors->all() as $error) - <li>{{ $error }}</li> - @endforeach - </ul> - </x-bs.alert> - @endif - - @if (session('success')) - <x-bs.alert type="success" class="mt-3"> - {{ session('success') }} - </x-bs.alert> - @endif </div> </div> @endsection diff --git a/resources/views/admin/settings/users.blade.php b/resources/views/admin/settings/users.blade.php index 57713da07d490c06ac3a5e2197c6641d2ebdc6de..a3bc900930e1c70365180da2a6845f843087cc05 100644 --- a/resources/views/admin/settings/users.blade.php +++ b/resources/views/admin/settings/users.blade.php @@ -7,7 +7,8 @@ par email.</p> <p><span class="fw-bold text-danger">Attention !</span> Pour des raisons de simplicité, ces personnes auront les - mêmes droits que vous (sauf toutes les actions liées à cette page, y compris son accès). + mêmes droits que vous (sauf toutes les actions liées à cette page et à la configuration du site, y compris + leur accès). <strong>Soyez bien certains d'avoir confiance en elles !</strong></p> <x-bs.button data-bs-toggle="modal" data-bs-target="#addUserModal" size="sm" variant="primary"> diff --git a/resources/views/admin/settings/website-config.blade.php b/resources/views/admin/settings/website-config.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..4e15c67a69f6f055c1c0a5c033fe304a6113a167 --- /dev/null +++ b/resources/views/admin/settings/website-config.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.admin', ['title' => 'Configuration', 'headerRoutes' => $headerRoutes]) + +@section('content') + <div class="grid"> + <div class="g-col-12 g-col-md-6"> + @if (!empty($errors) && $errors->any()) + <x-bs.alert type="danger" class="mt-3"> + <ul> + @foreach ($errors->all() as $error) + <li>{{ $error }}</li> + @endforeach + </ul> + </x-bs.alert> + @endif + + @if (session('success')) + <x-bs.alert type="success" class="mt-3"> + {{ session('success') }} + </x-bs.alert> + @endif + + <h5>Configuration</h5> + <p>Modifiez les paramètres du site internet.</p> + <form action="{{ route('admin.settings.update-website-config') }}" + method="post"> + @csrf + @method('PUT') + + <x-bs.select label="Statut du mode maintenance" + name="maintenance_mode" + class="mb-3" + :options="['false' => 'Désactivé', 'true' => 'Activé']" + :selected="old('maintenance_mode', \App\Models\DbConfig::get('maintenance_mode', 'true'))" + data-form-type="other" + /> + + <x-bs.input label="Nom du site internet" + name="website_name" + class="mb-3" + :value="old('website_name', \App\Models\DbConfig::get('website_name', 'Rann Graphic Design'))" + data-form-type="other" + /> + + <x-bs.button type="submit">Enregistrer</x-bs.button> + </form> + </div> + </div> +@endsection diff --git a/resources/views/components/admin/sidebar.blade.php b/resources/views/components/admin/sidebar.blade.php index 8358c73a3c50adced06c560f5e28f5d12c40e1dc..383a4b3d856cbeb330842c8fc4114a68f805359e 100644 --- a/resources/views/components/admin/sidebar.blade.php +++ b/resources/views/components/admin/sidebar.blade.php @@ -8,7 +8,7 @@ <a class="website-name" href="{{ route('admin.home') }}"> <img class="logo" src="{{ asset('favicon.svg') }}" alt="Website Logo"/> <span> - {{ config('app.name') }} + {{ \App\Models\DbConfig::get('website_name', "Rann Graphic Design") }} </span> </a> </div> diff --git a/resources/views/components/public/footer.blade.php b/resources/views/components/public/footer.blade.php index 05142fdf514118c6824e2c1982921cb4f779788b..de488c30d597cdcf66ee759c5d8e1a0adce70b94 100644 --- a/resources/views/components/public/footer.blade.php +++ b/resources/views/components/public/footer.blade.php @@ -8,20 +8,20 @@ <x-public.rann-logo height="4rem" role="image"/> </div> <div class="self-stretch flex-col justify-start items-start gap-2 flex"> - <div class="self-stretch text-2xl font-bold">Hâte de travailler avec vous !</div> - <div class="self-stretch text-muted">N’hésitez pas à me contacter si vous avez besoin de - renseignements, ou que vous souhaitez simplement discuter avec moi. + <div class="self-stretch text-2xl font-bold">{{ __('footer.contact.title') }}</div> + <div class="self-stretch text-muted"> + {{ __('footer.contact.desc') }} </div> </div> - <x-public.button size="medium" tag="a" href="#"> - <div class="text-xl font-bold">Me contacter</div> + <x-public.button size="medium" tag="a" href="{{ route('contact') }}"> + <div class="text-xl font-bold">{{ __('navbar.contact_me') }}</div> </x-public.button> </div> </div> <div class="w-full lg:w-auto items-start flex flex-col lg:flex-row gap-8 lg:gap-24"> <div class="w-full lg:w-56 flex-col gap-8 flex"> - <div class="self-stretch text-2xl font-bold">Restons en contact</div> + <div class="self-stretch text-2xl font-bold">{{ __('footer.links.title') }}</div> <ul class="flex flex-col gap-2"> @foreach(\App\Models\SocialMediaLink::all() as $socialMediaLink) <li> @@ -33,16 +33,15 @@ </ul> </div> <div class="w-full lg:w-56 flex-col gap-8 flex"> - <div class="self-stretch text-2xl font-bold">Informations légales</div> + <div class="self-stretch text-2xl font-bold">{{ __('footer.legals-infos.title') }}</div> <ul class="flex flex-col gap-2"> <li> - <a href="#" class="text-muted hover:text-primary">Mentions légales</a> + <a href="{{ route('legal-mentions') }}" + class="text-muted hover:text-primary">{{ __('generic.legal_mentions') }}</a> </li> <li> - <a href="#" class="text-muted hover:text-primary">Conditions générales de vente</a> - </li> - <li> - <a href="#" class="text-muted hover:text-primary">Politique de confidentialité</a> + <a href="{{ route('terms') }}" + class="text-muted hover:text-primary">{{ __('generic.terms') }}</a> </li> </ul> </div> diff --git a/resources/views/components/public/generic-page-header.blade.php b/resources/views/components/public/generic-page-header.blade.php index 7883746c0253491d0b38ca139d379b74f96a19ac..defaac73b1c21652eacf74dd4be847ddcc368361 100644 --- a/resources/views/components/public/generic-page-header.blade.php +++ b/resources/views/components/public/generic-page-header.blade.php @@ -1,6 +1,6 @@ @props([ 'title' => 'Title', - 'description' => 'Description', + 'description' => '', ]) <div {{ $attributes->class(['relative flex flex-col max-w-2xl gap-3']) }}> diff --git a/resources/views/components/public/navbar.blade.php b/resources/views/components/public/navbar.blade.php index 0c0612b1bfd4171d88c7cc3d51e6daad738b95b4..f4d69177c983f15c502f55f274dc573c730ff346 100644 --- a/resources/views/components/public/navbar.blade.php +++ b/resources/views/components/public/navbar.blade.php @@ -17,7 +17,7 @@ <a href="{{ route('prestations') }}" class="text-xl font-bold">{{ __('navbar.services') }}</a> <a href="{{ route('portfolio') }}" class="text-xl font-bold">{{ __('navbar.portfolio') }}</a> <a href="{{ route('evenements') }}" class="text-xl font-bold">{{ __('navbar.events') }}</a> - <a href="#" class="text-xl font-bold">{{ __('navbar.contact_me') }}</a> + <a href="{{ route('contact') }}" class="text-xl font-bold">{{ __('navbar.contact_me') }}</a> <x-public.button tag="a" href="https://dalnarabyrann.sumupstore.com/"> {{ __('navbar.shop') }} </x-public.button> @@ -41,7 +41,7 @@ class="fixed z-50 inset-0 bg-white bg-opacity-90 flex-col items-center justify-c <a href="{{ route('prestations') }}" class="text-2xl font-bold">{{ __('navbar.services') }}</a> <a href="{{ route('portfolio') }}" class="text-2xl font-bold">{{ __('navbar.portfolio') }}</a> <a href="{{ route('evenements') }}" class="text-2xl font-bold">{{ __('navbar.events') }}</a> - <a href="#" class="text-2xl font-bold">{{ __('navbar.contact_me') }}</a> + <a href="{{ route('contact') }}" class="text-2xl font-bold">{{ __('navbar.contact_me') }}</a> <x-public.button tag="a" href="https://dalnarabyrann.sumupstore.com/" class="text-xl"> {{ __('navbar.shop') }} </x-public.button> diff --git a/resources/views/layouts/public.blade.php b/resources/views/layouts/public.blade.php index f2a8ee119fe0b91ff7bf298db03819fd2e8c710d..51d9b8ef932156641ee214e8fb59941ef0e3a5c5 100644 --- a/resources/views/layouts/public.blade.php +++ b/resources/views/layouts/public.blade.php @@ -1,12 +1,34 @@ +@php + $websiteName = \App\Models\DbConfig::get('website_name', "Rann Graphic Design") +@endphp + <!DOCTYPE html> <html lang="{{ config('app.locale') }}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{ !empty($title) ? $title . ' - Rann Graphic Design' : 'Rann Graphic Design' }}</title> + <title>{{ !empty($title) ? $title . ' - ' . $websiteName : $websiteName }}</title> <link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}"/> + + <meta property="og:locale" content="{{ config('app.locale') }}"> + <meta property="og:locale:alternate" content="{{ config('app.fallback_locale') }}"> + + <meta property="og:title" content="{{ !empty($title) ? $title : $websiteName }}"> + <meta property="og:type" content="website"> + <meta property="og:url" content="{{ url()->current() }}"> + @if(!empty($description)) + <meta name="description" content="{{ $description }}"> + <meta property="og:description" content="{{ $description }}"> + @endif + @if(!empty($image) && is_a($image, \App\Models\UploadedPicture::class)) + <meta property="og:image" content="{{ $image->getFullsizeUrl() }}"> + <meta property="og:image:width" content="{{ $image->width }}"> + <meta property="og:image:height" content="{{ $image->height }}"> + @endif + <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Sniglet:wght@400;800&display=swap" rel="stylesheet"> @vite(['resources/scss/public.scss', 'resources/js/app.js']) </head> <body> diff --git a/resources/views/public/contact.blade.php b/resources/views/public/contact.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..74e56ac3a417a67230e323a6e33024e1ae361cfe --- /dev/null +++ b/resources/views/public/contact.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.public', ['title' => __('navbar.contact_me'), 'description' => __('contact.desc')]) + +@section('content') + <x-public.navbar class="container mx-auto px-4"/> + <div class="container mx-auto px-4 py-24 flex flex-col gap-16"> + <x-public.generic-page-header title="{{ __('navbar.contact_me') }}" :description="__('contact.desc')"/> + + <div> + <div class="text-center text-2xl"> + {{ __('contact.contact_me_on_one_of_my_social_media') }} + </div> + + <div class="flex flex-wrap align-center justify-center gap-4 mt-4"> + @foreach(\App\Models\SocialMediaLink::all() as $socialMediaLink) + <x-public.button tag="a" :href="$socialMediaLink->url" class="flex items-center gap-2" + size="medium"> + <div> + {{ $socialMediaLink->name }} + </div> + <div> + <x-font-awesome :icon="$socialMediaLink->icon_name" type="brands" class="w-6 h-6"/> + </div> + </x-public.button> + @endforeach + </div> + </div> + </div> + + <x-public.footer/> +@endsection \ No newline at end of file diff --git a/resources/views/public/index.blade.php b/resources/views/public/index.blade.php index 72ec9b60bf89d33f28586896b118e7ec2be50ec5..ed2c352e43576e2d6270065ef23cbba0c12874b3 100644 --- a/resources/views/public/index.blade.php +++ b/resources/views/public/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.public') +@extends('layouts.public', ['description' => __('home.meta_description')]) @section('content') <section class="HeroSection position-relative w-100"> @@ -21,11 +21,12 @@ class="absolute hidden 2xl:block left-[-96px] top-[-26px] origin-top-left rotate </span> <x-public.arrow class="hidden xl:block w-28 h-9"/> </span> - <span class="text-5xl xl:text-6xl font-bold"> + <span class="text-5xl xl:text-6xl"> {{ __('home.hero.second_row') }} </span> </h1> - <x-public.button size="large" tag="a" href="#" class="flex items-center gap-8"> + <x-public.button size="large" tag="a" href="{{ route('contact') }}" + class="flex items-center gap-8"> <img class="w-10 h-10" src="{{Vite::asset("resources/images/public/waving-hand.avif")}}" alt="{{ __('home.img_alt.waving_hand') }}" loading="eager"/> <div class="flex flex-col justify-center items-start"> @@ -71,18 +72,8 @@ class="Creations relative hidden xl:grid grow w-full aspect-[885/622]"/> <div class="w-full h-24 bg-gradient-to-b from-neutral-200 to-light"></div> <div class="container mx-auto px-4 flex flex-col justify-start items-center gap-2.5"> <div class="flex flex-col justify-start items-start w-full gap-16"> - <div class="flex flex-col justify-start items-start max-w-6xl"> - <div class="text-2xl font-bold">{{ __('home.about_me') }}</div> - <h2 class="text-5xl xl:text-6xl font-normal"> - @if(!$aboutMeSection->isEmpty()) - {{ $aboutMeSection->first()->attractDescTransKey->getTranslation() }} - @else - Lorem ipsum dolor sit amet, - @endif - </h2> - </div> - <div class="flex flex-col xl:flex-row justify-start items-center w-full gap-4 xl:gap-0"> - <div class="flex shrink-0 basis-1/2 justify-center items-center p-4 xl:p-0 gap-2.5"> + <div class="flex flex-col xl:flex-row justify-start w-full gap-8 xl:gap-16"> + <div class="flex shrink-0 justify-center items-center p-4 xl:p-0 gap-2.5"> <div class="overflow-hidden w-full xl:w-[25rem] aspect-square rounded-2xl shadow-[-.5rem_.5rem_0px_0px_rgba(0,0,0,1.00)] shadow-pink-normal"> @if(!$aboutMeSection->isEmpty()) <img class="w-full h-full object-cover" @@ -95,8 +86,17 @@ class="Creations relative hidden xl:grid grow w-full aspect-[885/622]"/> @endif </div> </div> - <div class="flex flex-col shrink-0 basis-1/2 py-2.5 justify-center items-start gap-4"> - <x-markdown class="flex flex-col gap-3 text-muted text-xl xl:text-3xl font-normal"> + <div class="flex flex-col py-2.5 items-start gap-4"> + <div class="text-2xl font-bold">{{ __('home.about_me') }}</div> + <h2 class="text-5xl xl:text-6xl font-normal mb-8"> + @if(!$aboutMeSection->isEmpty()) + {{ $aboutMeSection->first()->attractDescTransKey->getTranslation() }} + @else + Lorem ipsum dolor sit amet, + @endif + </h2> + + <x-markdown class="flex flex-col gap-3 text-muted text-xl xl:text-2xl font-normal"> @if(!$aboutMeSection->isEmpty()) {{ $aboutMeSection->first()->contentTransKey->getTranslation() }} @else diff --git a/resources/views/public/legal-mentions.blade.php b/resources/views/public/legal-mentions.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..65d3c5cc42d7c0a94a6e7782337eb2535d96c795 --- /dev/null +++ b/resources/views/public/legal-mentions.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.public', ['title' => __('generic.legal_mentions')]) + +@section('content') + <x-public.navbar class="container mx-auto px-4"/> + + <div class="container mx-auto px-4 py-24 flex flex-col gap-16"> + <x-public.generic-page-header title="{{ __('generic.legal_mentions') }}"/> + <div> + <x-markdown class="markdown"> + {{ $activeLegalMentionsText }} + </x-markdown> + </div> + </div> + + <x-public.footer/> +@endsection \ No newline at end of file diff --git a/resources/views/public/maintenance.blade.php b/resources/views/public/maintenance.blade.php index 0df958413fd3073599f2ed770dc9887ff542ceb7..15da0114fd74c35f542237191938e6c3cf5a107c 100644 --- a/resources/views/public/maintenance.blade.php +++ b/resources/views/public/maintenance.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.public', ['title' => 'Maintenance']) +@extends('layouts.public', ['title' => 'Maintenance', 'description' => 'Site en construction...']) @section('content') <div class="inline-flex w-full min-h-screen flex-col justify-center items-center gap-2.5" diff --git a/resources/views/public/portfolio-show.blade.php b/resources/views/public/portfolio-show.blade.php index 9a8127f9722df173ee58f04ba33b39f773de5bca..bd3173a5e49d5173275150093e37c4031cea937a 100644 --- a/resources/views/public/portfolio-show.blade.php +++ b/resources/views/public/portfolio-show.blade.php @@ -1,4 +1,8 @@ -@extends('layouts.public', ['title' => $creation->nameTranslationKey->getTranslation()]) +@extends('layouts.public', [ + 'title' => $creation->nameTranslationKey->getTranslation(), + 'description' => $creation->shortDescriptionTranslationKey->getTranslation(), + 'image' => $creation->coverUploadedPicture +]) @section('content') <x-public.navbar class="container mx-auto px-4"/> diff --git a/resources/views/public/portfolio.blade.php b/resources/views/public/portfolio.blade.php index 11eb8b9f18bfd49d4f1d7269f0d2be519117f94a..5f001c4f7e15b224c42256fb73f56c73634e2ea6 100644 --- a/resources/views/public/portfolio.blade.php +++ b/resources/views/public/portfolio.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.public', ['title' => __('navbar.portfolio')]) +@extends('layouts.public', ['title' => __('navbar.portfolio'), 'description' => __('services.description')]) @section('content') <x-public.navbar class="container mx-auto px-4"/> diff --git a/resources/views/public/prestation.blade.php b/resources/views/public/prestation.blade.php index 7fd1dc6cba32231a337c539297609523825db2db..e998491d84ac3f4faee9d8a030233cde6eca272c 100644 --- a/resources/views/public/prestation.blade.php +++ b/resources/views/public/prestation.blade.php @@ -1,4 +1,8 @@ -@extends('layouts.public', ['title' => $prestation->nameTransKey->getTranslation()]) +@extends('layouts.public', [ + 'title' => $prestation->nameTransKey->getTranslation(), + 'description' => $prestation->attractDescTransKey->getTranslation(), + 'image' => $prestation->uploadedPicture +]) @section('content') <x-public.navbar class="container mx-auto px-4"/> @@ -45,7 +49,7 @@ class="w-full relative aspect-square"/> {{ __('generic.custom_needs_desc') }} </div> </div> - <x-public.button size="medium" tag="a" href="#"> + <x-public.button size="medium" tag="a" href="{{ route('contact') }}"> <div class="text-xl font-bold">{{ __('navbar.contact_me') }}</div> </x-public.button> </div> diff --git a/resources/views/public/prestations.blade.php b/resources/views/public/prestations.blade.php index 5d052fa588e10abe72c7c0f7580a09c50b906776..20b99d8a7fffe9978cb0085f02501ad9b0fc8614 100644 --- a/resources/views/public/prestations.blade.php +++ b/resources/views/public/prestations.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.public', ['title' => __('navbar.services')]) +@extends('layouts.public', ['title' => __('navbar.services'), 'description' => __('services.attract_desc')]) @section('content') <x-public.navbar class="container mx-auto px-4"/> @@ -17,7 +17,7 @@ {{ __('generic.custom_needs_desc') }} </div> </div> - <x-public.button size="medium" tag="a" href="#"> + <x-public.button size="medium" tag="a" href="{{ route('contact') }}"> <div class="text-xl font-bold">{{ __('navbar.contact_me') }}</div> </x-public.button> </div> diff --git a/resources/views/public/terms.blade.php b/resources/views/public/terms.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..c7d919ab651ef56b0fc097ccca6d36e41e0cdc9d --- /dev/null +++ b/resources/views/public/terms.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.public', ['title' => __('generic.terms')]) + +@section('content') + <x-public.navbar class="container mx-auto px-4"/> + + <div class="container mx-auto px-4 py-24 flex flex-col gap-16"> + <x-public.generic-page-header title="{{ __('generic.terms') }}"/> + <div> + <x-markdown class="markdown"> + {{ $activeTermsText }} + </x-markdown> + </div> + </div> + + <x-public.footer/> +@endsection \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 0ef4bc96588dfaa08fe439c99b9ac6e9c86345ed..37195099b5989fd118ad261513cef434bea7a23b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,6 +9,7 @@ Schedule::command('save:requests')->everyMinute()->sentryMonitor(); Schedule::command('process:user-agents')->hourly()->sentryMonitor(); +Schedule::command('process:ip-adresses')->everyFiveMinutes()->sentryMonitor(); Schedule::command('flush:unused-uploaded-pictures')->daily()->sentryMonitor(); Schedule::command('backup:clean --disable-notifications')->daily()->at('01:00'); diff --git a/routes/web.php b/routes/web.php index aa637dc52583b1a3b5e1c9d22841990f31785ee2..3049d6c2b25ac7f4dede3368ce149133bca9d192 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,8 +11,10 @@ use App\Http\Controllers\Admin\SocialMediaLinksController; use App\Http\Controllers\Admin\TermsSectionController; use App\Http\Controllers\Public\IndexController; +use App\Http\Controllers\Public\LegalMentionsController; use App\Http\Controllers\Public\PortfolioController; use App\Http\Controllers\Public\PrestationController as PublicPrestationController; +use App\Http\Controllers\Public\TermsController; use App\Http\Middleware\CheckPrivateModeMiddleware; use App\Http\Middleware\RedirectIfUserExistsMiddleware; use Illuminate\Support\Facades\Route; @@ -29,6 +31,9 @@ Route::get('/portfolio/api', [PortfolioController::class, 'api'])->name('portfolio.api'); Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show'); Route::get('/prestations/{slug}', [PublicPrestationController::class, 'index'])->name('prestations.show'); + Route::get('/mentions-legales', LegalMentionsController::class)->name('legal-mentions'); + Route::get('/conditions-generales-de-vente', TermsController::class)->name('terms'); + Route::view('/contact', 'public.contact')->name('contact'); Route::view('/maintenance', 'public.maintenance')->name('maintenance'); }); @@ -74,6 +79,8 @@ ->name('delete'); Route::delete('/{creation}/remove-addtionnal-image/{image}', [CreationController::class, 'removeAdditionalImage']) ->name('remove-additionnal-image'); + Route::get('/{creation}/translate-with-ai', [CreationController::class, 'translateWithAi']) + ->name('translate-with-ai'); }); Route::prefix('social-media-links')->name('social-media-links.')->group(function () { @@ -106,6 +113,8 @@ Route::match(['GET', 'DELETE'], '/users/{id}/delete', [SettingsController::class, 'deleteUser'])->name('delete-user'); Route::get('/general', [SettingsController::class, 'generalPage'])->name('general'); Route::put('/general', [SettingsController::class, 'updateUserInfos'])->name('update-user-infos'); + Route::get('/website-configuration', [SettingsController::class, 'websiteConfigPage'])->name('website-config'); + Route::put('/website-configuration', [SettingsController::class, 'updateWebsiteConfig'])->name('update-website-config'); }); Route::prefix('prestations')->name('prestations.')->group(function () { diff --git a/tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php b/tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php similarity index 86% rename from tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php rename to tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php index 0d30022ee60d508e8dbaf02a80e4fb216bda598c..c362a64e7c121bee5b6d32811e0b114a02823342 100644 --- a/tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php +++ b/tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\FlushUnusedUploadedPicturesCommand; use App\Models\UploadedPicture; @@ -9,7 +9,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; -#[CoversClass(FlushUnusedUploadedPicturesCommand::class)] class FlushUnusedUploadedPicturesCommandTest extends TestCase +#[CoversClass(FlushUnusedUploadedPicturesCommand::class)] +class FlushUnusedUploadedPicturesCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php b/tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php similarity index 89% rename from tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php rename to tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php index f9f09eefc283cf17693a50f4373d9818e0c10848..8863523704bf1d11e5b1cb36f88ba5cbdf012954 100644 --- a/tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php +++ b/tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\OptimizeUploadedPicturesCommand; use App\Models\UploadedPicture; @@ -12,7 +12,8 @@ /** * Tests de la commande d'optimisation des images uploadées */ -#[CoversClass(OptimizeUploadedPicturesCommand::class)] class OptimizeUploadedPicturesCommandTest extends TestCase +#[CoversClass(OptimizeUploadedPicturesCommand::class)] +class OptimizeUploadedPicturesCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php b/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..87153e4c3ca3e11f8d6d9d78003014968bbadf11 --- /dev/null +++ b/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php @@ -0,0 +1,28 @@ +<?php + +namespace Tests\Feature\Console\Commands; + +use App\Console\Commands\ProcessIpAdressesCommand; +use App\Jobs\ProcessIpAddressesJob; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Queue; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(ProcessIpAdressesCommand::class)] +class ProcessIpAdressesCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_it_dispatches_job() + { + Queue::fake(); + IpAddress::factory()->count(10)->create(); + + Artisan::call('process:ip-adresses'); + + Queue::assertPushed(ProcessIpAddressesJob::class); + } +} diff --git a/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php b/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c6390dc1a8ee4b9cd4845a767bf1514ef6dbb4ae --- /dev/null +++ b/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php @@ -0,0 +1,27 @@ +<?php + +namespace Tests\Feature\Console\Commands; + +use App\Console\Commands\ProcessUserAgentsCommand; +use App\Jobs\ProcessUserAgentJob; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\UserAgent; +use Tests\TestCase; + +#[CoversClass(ProcessUserAgentsCommand::class)] +class ProcessUserAgentsCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_it_dispatches_job() + { + Queue::fake(); + UserAgent::factory()->count(10)->create(); + + $this->artisan('process:user-agents'); + + Queue::assertPushed(ProcessUserAgentJob::class, 10); + } +} diff --git a/tests/Feature/Console/Command/UpdateImageDimensionsTest.php b/tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php similarity index 92% rename from tests/Feature/Console/Command/UpdateImageDimensionsTest.php rename to tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php index df50701450a1af1058cbe8f37272250ab2420b43..99b45783e993c20d5a5d156556b09330626dc42c 100644 --- a/tests/Feature/Console/Command/UpdateImageDimensionsTest.php +++ b/tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\UpdateImageDimensionsCommand; use App\Models\UploadedPicture; @@ -15,7 +15,8 @@ /** * Tests de la commande de mise à jour des dimensions des images */ -#[CoversClass(UpdateImageDimensionsCommand::class)] class UpdateImageDimensionsTest extends TestCase +#[CoversClass(UpdateImageDimensionsCommand::class)] +class UpdateImageDimensionsCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 22ef0c599c0a71232c7b2b1ff5c5921e0392c50f..0000000000000000000000000000000000000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,21 +0,0 @@ -<?php - -namespace Tests\Feature; - -use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - -class ExampleTest extends TestCase -{ - use RefreshDatabase; - - /** - * A basic test example. - */ - public function test_the_application_returns_a_successful_response(): void - { - $response = $this->get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/Jobs/ProcessIpAddressesJobTest.php b/tests/Feature/Jobs/ProcessIpAddressesJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..005713708d5713d40fe35eb84d601cc9b826f14c --- /dev/null +++ b/tests/Feature/Jobs/ProcessIpAddressesJobTest.php @@ -0,0 +1,59 @@ +<?php + +namespace Tests\Feature\Jobs; + +use App\Jobs\ProcessIpAddressesJob; +use App\Services\IpAddressMetadataResolverService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(ProcessIpAddressesJob::class)] +class ProcessIpAddressesJobTest extends TestCase +{ + use RefreshDatabase; + + public function test_processes_ip_addresses_successfully() + { + $ip1 = IpAddress::factory()->create(['ip' => '208.80.152.201']); + $ip2 = IpAddress::factory()->create(['ip' => '24.48.0.1']); + + Http::fake([ + '*' => Http::response([ + [ + 'status' => 'success', + 'countryCode' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + 'query' => '208.80.152.201', + ], + [ + 'status' => 'success', + 'countryCode' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + 'query' => '24.48.0.1', + ], + ]), + ]); + + $job = new ProcessIpAddressesJob(collect([$ip1, $ip2])); + $job->handle(app(IpAddressMetadataResolverService::class)); + + $this->assertDatabaseHas('ip_address_metadata', [ + 'ip_address_id' => $ip1->id, + 'country_code' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + ]); + + $this->assertDatabaseHas('ip_address_metadata', [ + 'ip_address_id' => $ip2->id, + 'country_code' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + ]); + } +} diff --git a/tests/Feature/Service/AiProviderServiceTest.php b/tests/Feature/Service/AiProviderServiceTest.php index 4cd5d62a5e19ca3c658c2a4fb3a9a3918fb44b02..1de43af3942ac979ba9f366f06300529db396eb9 100644 --- a/tests/Feature/Service/AiProviderServiceTest.php +++ b/tests/Feature/Service/AiProviderServiceTest.php @@ -60,7 +60,7 @@ public function test_prompt_with_pictures_sends_correct_request() { Storage::fake('public'); Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); $mockTranscodingService = Mockery::mock(ImageTranscodingService::class); @@ -91,7 +91,7 @@ public function test_prompt_with_pictures_sends_correct_request() public function test_prompt_sends_correct_request() { Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); Config::set('ai-provider.selected-provider', 'test-provider'); @@ -116,7 +116,7 @@ public function test_prompt_with_pictures_handles_transcoding_failure() { Storage::fake('public'); Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); $mockTranscodingService = Mockery::mock(ImageTranscodingService::class); diff --git a/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php b/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..dcb6c86b358cbf6c93a836a1711927efbfc5e395 --- /dev/null +++ b/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Tests\Feature\Service; + +use App\Services\IpAddressMetadataResolverService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Exceptions; +use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(IpAddressMetadataResolverService::class)] +class IpAddressMetadataResolverServiceTest extends TestCase +{ + use RefreshDatabase; + + public function test_resolves_ip_addresses_successfully() + { + Exceptions::fake(); + + Http::fake([ + '*' => Http::response([ + [ + 'status' => 'success', + 'countryCode' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + 'query' => '208.80.152.201', + ], + [ + 'status' => 'success', + 'countryCode' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + 'query' => '24.48.0.1', + ], + ]), + ]); + + $service = new IpAddressMetadataResolverService; + + $ipAddresses = IpAddress::factory()->count(2)->create(); + $result = $service->resolve($ipAddresses); + + Exceptions::assertNothingReported(); + $this->assertCount(2, $result); + $this->assertEquals('US', $result[0]['countryCode']); + $this->assertEquals('CA', $result[1]['countryCode']); + } +}