Compare commits
9 Commits
bugfixes/e
...
f7c867e13c
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c867e13c | |||
| 2f2b7866c6 | |||
| f85b596a66 | |||
| 9b8c273c21 | |||
| bbfc64031f | |||
| fdc10a0acb | |||
| c60738b532 | |||
| 38943743aa | |||
| 866118a86f |
123
README.md
123
README.md
@@ -1,79 +1,106 @@
|
|||||||
# Interactieve Laravel CV Applicatie
|
# CV Roberto
|
||||||
|
|
||||||
Deze Laravel-applicatie is gebouwd als mijn interactieve en dynamische cv. In plaats van een statisch pdf-bestand, kun je hier real-time mijn:
|
Dit project is mijn interactieve CV en tegelijk een voorbeeld van hoe ik een Laravel-app opzet.
|
||||||
|
|
||||||
- **Vaardigheden** (met beoordeling en iconen)
|
Live: [cv.robert.ooo](https://cv.robert.ooo)
|
||||||
- **Werkervaring**
|
|
||||||
- **Opleidingen**
|
|
||||||
- **Persoonlijke gegevens**
|
|
||||||
- **Tags & kernkwaliteiten**
|
|
||||||
|
|
||||||
zien — inclusief slimme automatisering, logging en Telegram-notificaties voor recruiterinteracties.
|
## Wat zit erin
|
||||||
|
|
||||||
---
|
- Publieke CV-pagina met werkervaring, opleidingen, skills en personalia.
|
||||||
|
- Adminomgeving om CV-data te beheren.
|
||||||
|
- Afbeeldingsuploads via Spatie Media Library.
|
||||||
|
- Contactformulier met queue job voor Telegram-notificaties.
|
||||||
|
- Klikbare verborgen personalia, zodat bots de waarde niet direct in de HTML zien.
|
||||||
|
- Feature tests voor de belangrijkste controllerflows.
|
||||||
|
- CI-checks voor tests, Larastan en formatting.
|
||||||
|
|
||||||
## 🧰 Techniek & Stack
|
## Stack
|
||||||
|
|
||||||
- **Framework:** Laravel 12
|
- Laravel 12
|
||||||
- **Frontend:** Tailwind CSS, Blade
|
- PHP 8.2+
|
||||||
- **DevOps-integraties:** Telegram alerts, Healthchecks, custom logging
|
- Blade
|
||||||
- **CI/CD-ready:** Ondersteuning voor deploy hooks en jobs
|
- Tailwind CSS
|
||||||
- **Overige tools:** Docker, Nginx, Git, Redis, Cron, Promtail, Grafana
|
- Pest
|
||||||
|
- Larastan level 7
|
||||||
|
- Laravel Pint
|
||||||
|
- Blade Formatter
|
||||||
|
- Docker / Laravel Sail
|
||||||
|
|
||||||
---
|
## Kwaliteitschecks
|
||||||
|
|
||||||
## ⚙️ Installatie
|
Deze checks horen groen te zijn voordat een merge logisch is:
|
||||||
|
|
||||||
1. **Clone deze repo:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/roberto-guagliardo/cv-app.git
|
composer test
|
||||||
cd cv-app
|
composer analyse
|
||||||
|
composer format:check
|
||||||
|
npm run format:check
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Installeer dependencies:**
|
In de workflow worden dezelfde checks afgedwongen:
|
||||||
|
|
||||||
```bash
|
- PHPUnit/Pest feature tests
|
||||||
composer install
|
- Larastan op level 7
|
||||||
npm install && npm run build
|
- PHP formatting via Pint
|
||||||
```
|
- Blade formatting via Blade Formatter
|
||||||
|
|
||||||
3. **Maak .env aan en configureer je database, storage en Telegram:**
|
Skipped tests zijn alleen acceptabel als dat bewust is, zoals disabled registratieflows. Larastan en formatting moeten gewoon groen zijn.
|
||||||
|
|
||||||
|
## Lokaal draaien
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
composer install
|
||||||
|
npm ci
|
||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
```
|
|
||||||
|
|
||||||
4. **Voer migraties & seeders uit:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan migrate --seed
|
php artisan migrate --seed
|
||||||
```
|
npm run build
|
||||||
|
|
||||||
5. **Geniet van de app:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan serve
|
php artisan serve
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Met Sail:
|
||||||
|
|
||||||
## 🌐 Live Demo
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
./vendor/bin/sail up -d
|
||||||
|
./vendor/bin/sail artisan key:generate
|
||||||
|
./vendor/bin/sail artisan migrate --seed
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
Wil je zien hoe het eruitziet? Bekijk mijn live cv op:
|
Voor de admin login kun je in `.env` deze waardes zetten en daarna opnieuw seeden:
|
||||||
|
|
||||||
➡️ [cv.robert.ooo](https://cv.robert.ooo)
|
```env
|
||||||
|
ADMIN_NAME="Admin"
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=changeme123
|
||||||
|
```
|
||||||
|
|
||||||
---
|
## Development
|
||||||
|
|
||||||
## 📊 Bijdragen
|
PHP formatter:
|
||||||
|
|
||||||
Deze applicatie is persoonlijk en niet bedoeld voor publieke bijdragen, maar voel je vrij om de structuur of ideeën te gebruiken voor je eigen showcase-app.
|
```bash
|
||||||
|
composer format
|
||||||
|
composer format:check
|
||||||
|
```
|
||||||
|
|
||||||
---
|
Blade formatter:
|
||||||
|
|
||||||
## 💌 Contact
|
```bash
|
||||||
|
npm run format
|
||||||
|
npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
Wil je mij benaderen voor een samenwerking of opdracht?
|
Tests en analyse:
|
||||||
Gebruik het contactformulier op de site of stuur direct een bericht via [Telegram](https://t.me/robertguagliardo).
|
|
||||||
|
```bash
|
||||||
|
composer test
|
||||||
|
composer analyse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Gebruik het contactformulier op de site of stuur me een bericht via [Telegram](https://t.me/robertguagliardo).
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\PageVisitRequest;
|
||||||
use App\Jobs\NotifyTelegramAboutContactMessage;
|
use App\Jobs\NotifyTelegramAboutContactMessage;
|
||||||
|
use App\Jobs\NotifyTelegramAboutPageVisit;
|
||||||
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
||||||
use App\Models\Education;
|
use App\Models\Education;
|
||||||
use App\Models\Personalia;
|
use App\Models\Personalia;
|
||||||
@@ -58,4 +60,16 @@ class FrontendController extends Controller
|
|||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pageVisit(PageVisitRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
NotifyTelegramAboutPageVisit::dispatch(
|
||||||
|
$request->validated(),
|
||||||
|
$request->ip(),
|
||||||
|
$request->userAgent(),
|
||||||
|
$request->header('Accept-Language')
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'queued']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Http/Requests/PageVisitRequest.php
Normal file
43
app/Http/Requests/PageVisitRequest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PageVisitRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'visitor_id' => ['required', 'string', 'max:100'],
|
||||||
|
'url' => ['required', 'url', 'max:2048'],
|
||||||
|
'path' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'referrer' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'language' => ['nullable', 'string', 'max:50'],
|
||||||
|
'timezone' => ['nullable', 'string', 'max:100'],
|
||||||
|
'screen' => ['nullable', 'array'],
|
||||||
|
'screen.width' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||||
|
'screen.height' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||||
|
'viewport' => ['nullable', 'array'],
|
||||||
|
'viewport.width' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||||
|
'viewport.height' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||||
|
'device_pixel_ratio' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||||
|
'color_depth' => ['nullable', 'integer', 'min:0', 'max:1000'],
|
||||||
|
'platform' => ['nullable', 'string', 'max:255'],
|
||||||
|
'vendor' => ['nullable', 'string', 'max:255'],
|
||||||
|
'hardware_concurrency' => ['nullable', 'integer', 'min:0', 'max:1024'],
|
||||||
|
'device_memory' => ['nullable', 'numeric', 'min:0', 'max:1024'],
|
||||||
|
'cookies_enabled' => ['nullable', 'boolean'],
|
||||||
|
'online' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\TelegramNotificationService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
class NotifyTelegramAboutContactMessage implements ShouldQueue
|
class NotifyTelegramAboutContactMessage implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -35,30 +35,15 @@ class NotifyTelegramAboutContactMessage implements ShouldQueue
|
|||||||
$this->phone = $phone;
|
$this->phone = $phone;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(TelegramNotificationService $telegram): void
|
||||||
{
|
{
|
||||||
$email = $this->email ?? '–';
|
$telegram->notifyContactMessage(
|
||||||
$phone = $this->phone ?? '–';
|
$this->name,
|
||||||
|
$this->message,
|
||||||
$text = <<<TEXT
|
$this->ip,
|
||||||
📩 *Nieuw contactbericht ontvangen*
|
$this->userAgent,
|
||||||
|
$this->email,
|
||||||
👤 Naam: *{$this->name}*
|
$this->phone
|
||||||
|
);
|
||||||
💬 Bericht:
|
|
||||||
{$this->message}
|
|
||||||
📧 Email: {$email}
|
|
||||||
📱 Telefoon: {$phone}
|
|
||||||
🌐 IP: {$this->ip}
|
|
||||||
🧭 User Agent: `{$this->userAgent}`
|
|
||||||
|
|
||||||
🕒 Tijdstip: *{now()->format('d-m-Y H:i')}*
|
|
||||||
TEXT;
|
|
||||||
|
|
||||||
Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [
|
|
||||||
'chat_id' => config('services.telegram.chat_id'),
|
|
||||||
'text' => $text,
|
|
||||||
'parse_mode' => 'Markdown',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/Jobs/NotifyTelegramAboutPageVisit.php
Normal file
30
app/Jobs/NotifyTelegramAboutPageVisit.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\TelegramNotificationService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class NotifyTelegramAboutPageVisit implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $visit
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
protected array $visit,
|
||||||
|
protected ?string $ip,
|
||||||
|
protected ?string $userAgent,
|
||||||
|
protected ?string $acceptLanguage
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(TelegramNotificationService $telegram): void
|
||||||
|
{
|
||||||
|
$telegram->notifyPageVisit($this->visit, $this->ip, $this->userAgent, $this->acceptLanguage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Personalia;
|
use App\Models\Personalia;
|
||||||
|
use App\Services\TelegramNotificationService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
class NotifyTelegramAboutPersonaliaClick implements ShouldQueue
|
class NotifyTelegramAboutPersonaliaClick implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -27,25 +27,8 @@ class NotifyTelegramAboutPersonaliaClick implements ShouldQueue
|
|||||||
$this->userAgent = $userAgent;
|
$this->userAgent = $userAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(TelegramNotificationService $telegram): void
|
||||||
{
|
{
|
||||||
$ip = $this->ip ?? '–';
|
$telegram->notifyPersonaliaClick($this->personalia, $this->ip, $this->userAgent);
|
||||||
$userAgent = $this->userAgent ?? '–';
|
|
||||||
|
|
||||||
$message = <<<TEXT
|
|
||||||
👤 *Persoonlijke gegevens bekeken*
|
|
||||||
|
|
||||||
Naam: {$this->personalia->value}
|
|
||||||
IP: {$ip}
|
|
||||||
User Agent: `{$userAgent}`
|
|
||||||
|
|
||||||
📅 Tijdstip: *{now()->format('d-m-Y H:i')}*
|
|
||||||
TEXT;
|
|
||||||
|
|
||||||
Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [
|
|
||||||
'chat_id' => config('services.telegram.chat_id'),
|
|
||||||
'text' => $message,
|
|
||||||
'parse_mode' => 'Markdown',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
app/Services/TelegramNotificationService.php
Normal file
146
app/Services/TelegramNotificationService.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Personalia;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class TelegramNotificationService
|
||||||
|
{
|
||||||
|
public function notifyContactMessage(
|
||||||
|
string $name,
|
||||||
|
string $message,
|
||||||
|
string $ip,
|
||||||
|
string $userAgent,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phone = null
|
||||||
|
): void {
|
||||||
|
$email ??= '-';
|
||||||
|
$phone ??= '-';
|
||||||
|
|
||||||
|
$text = <<<TEXT
|
||||||
|
Nieuw contactbericht ontvangen
|
||||||
|
|
||||||
|
Naam: {$name}
|
||||||
|
Email: {$email}
|
||||||
|
Telefoon: {$phone}
|
||||||
|
IP: {$ip}
|
||||||
|
User Agent: {$userAgent}
|
||||||
|
|
||||||
|
Bericht:
|
||||||
|
{$message}
|
||||||
|
|
||||||
|
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
$this->send($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifyPersonaliaClick(Personalia $personalia, ?string $ip, ?string $userAgent): void
|
||||||
|
{
|
||||||
|
$ip ??= '-';
|
||||||
|
$userAgent ??= '-';
|
||||||
|
|
||||||
|
$message = <<<TEXT
|
||||||
|
Persoonlijke gegevens bekeken
|
||||||
|
|
||||||
|
Naam: {$personalia->value}
|
||||||
|
IP: {$ip}
|
||||||
|
User Agent: {$userAgent}
|
||||||
|
|
||||||
|
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
$this->send($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $visit
|
||||||
|
*/
|
||||||
|
public function notifyPageVisit(array $visit, ?string $ip, ?string $userAgent, ?string $acceptLanguage): void
|
||||||
|
{
|
||||||
|
$screen = $this->formatDimensions($visit['screen'] ?? null);
|
||||||
|
$viewport = $this->formatDimensions($visit['viewport'] ?? null);
|
||||||
|
|
||||||
|
$message = <<<TEXT
|
||||||
|
Pagina bezocht
|
||||||
|
|
||||||
|
URL: {$this->value($visit['url'] ?? null)}
|
||||||
|
Pad: {$this->value($visit['path'] ?? null)}
|
||||||
|
Titel: {$this->value($visit['title'] ?? null)}
|
||||||
|
Referrer: {$this->value($visit['referrer'] ?? null)}
|
||||||
|
|
||||||
|
Visitor ID: {$this->value($visit['visitor_id'] ?? null)}
|
||||||
|
IP: {$this->value($ip)}
|
||||||
|
User Agent: {$this->value($userAgent)}
|
||||||
|
Accept-Language: {$this->value($acceptLanguage)}
|
||||||
|
Browser taal: {$this->value($visit['language'] ?? null)}
|
||||||
|
Timezone: {$this->value($visit['timezone'] ?? null)}
|
||||||
|
Platform: {$this->value($visit['platform'] ?? null)}
|
||||||
|
Vendor: {$this->value($visit['vendor'] ?? null)}
|
||||||
|
Scherm: {$screen}
|
||||||
|
Viewport: {$viewport}
|
||||||
|
DPR: {$this->value($visit['device_pixel_ratio'] ?? null)}
|
||||||
|
Color depth: {$this->value($visit['color_depth'] ?? null)}
|
||||||
|
CPU cores: {$this->value($visit['hardware_concurrency'] ?? null)}
|
||||||
|
Device memory: {$this->value($visit['device_memory'] ?? null)}
|
||||||
|
Cookies: {$this->boolean($visit['cookies_enabled'] ?? null)}
|
||||||
|
Online: {$this->boolean($visit['online'] ?? null)}
|
||||||
|
|
||||||
|
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
$this->send($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function send(string $text): void
|
||||||
|
{
|
||||||
|
$botToken = config('services.telegram.bot_token');
|
||||||
|
$chatId = config('services.telegram.chat_id');
|
||||||
|
|
||||||
|
if (! is_string($botToken) || $botToken === '' || ! is_string($chatId) || $chatId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'text' => $text,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function value(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $this->boolean($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function boolean(mixed $value): string
|
||||||
|
{
|
||||||
|
return match ($value) {
|
||||||
|
true => 'ja',
|
||||||
|
false => 'nee',
|
||||||
|
default => '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatDimensions(mixed $dimensions): string
|
||||||
|
{
|
||||||
|
if (! is_array($dimensions)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = $this->value($dimensions['width'] ?? null);
|
||||||
|
$height = $this->value($dimensions['height'] ?? null);
|
||||||
|
|
||||||
|
return "{$width}x{$height}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const cursor = document.getElementById('custom-cursor');
|
window.addEventListener('load', () => {
|
||||||
|
const cursor = document.getElementById('custom-cursor');
|
||||||
|
const gsapInstance = window.gsap;
|
||||||
|
|
||||||
document.addEventListener('mousemove', (e) => {
|
if (!cursor || !gsapInstance) {
|
||||||
gsap.to(cursor, {
|
return;
|
||||||
duration: 0.2,
|
}
|
||||||
x: e.clientX + 20,
|
|
||||||
y: e.clientY - 15,
|
document.addEventListener('mousemove', (e) => {
|
||||||
ease: 'power2.out'
|
gsapInstance.to(cursor, {
|
||||||
|
duration: 0.2,
|
||||||
|
x: e.clientX + 20,
|
||||||
|
y: e.clientY - 15,
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,63 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||||
|
|
||||||
|
if (!csrfToken || !window.fetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitorKey = 'cv_visitor_id';
|
||||||
|
const createVisitorId = () => window.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
let visitorId = createVisitorId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
visitorId = window.localStorage.getItem(visitorKey) || visitorId;
|
||||||
|
window.localStorage.setItem(visitorKey, visitorId);
|
||||||
|
} catch {
|
||||||
|
// Tracking should never break the page when storage is blocked.
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch('{{ route('page-visits.store') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
keepalive: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
visitor_id: visitorId,
|
||||||
|
url: window.location.href,
|
||||||
|
path: window.location.pathname,
|
||||||
|
title: document.title,
|
||||||
|
referrer: document.referrer,
|
||||||
|
language: navigator.language,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
screen: {
|
||||||
|
width: window.screen?.width,
|
||||||
|
height: window.screen?.height,
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
},
|
||||||
|
device_pixel_ratio: window.devicePixelRatio,
|
||||||
|
color_depth: window.screen?.colorDepth,
|
||||||
|
platform: navigator.platform,
|
||||||
|
vendor: navigator.vendor,
|
||||||
|
hardware_concurrency: navigator.hardwareConcurrency,
|
||||||
|
device_memory: navigator.deviceMemory,
|
||||||
|
cookies_enabled: navigator.cookieEnabled,
|
||||||
|
online: navigator.onLine,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
@stack('scripts')
|
@stack('scripts')
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Route::get('/dashboard', function (): \Illuminate\View\View {
|
|||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
Route::get('/getPersonalia/{personalia}', [FrontendController::class, 'getPersonalia'])->name('personalia');
|
Route::get('/getPersonalia/{personalia}', [FrontendController::class, 'getPersonalia'])->name('personalia');
|
||||||
Route::post('/contact', [FrontendController::class, 'message'])->name('contact');
|
Route::post('/contact', [FrontendController::class, 'message'])->name('contact');
|
||||||
|
Route::post('/page-visits', [FrontendController::class, 'pageVisit'])->name('page-visits.store');
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\NotifyTelegramAboutContactMessage;
|
use App\Jobs\NotifyTelegramAboutContactMessage;
|
||||||
|
use App\Jobs\NotifyTelegramAboutPageVisit;
|
||||||
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
||||||
use App\Models\Education;
|
use App\Models\Education;
|
||||||
use App\Models\Personalia;
|
use App\Models\Personalia;
|
||||||
@@ -99,3 +100,55 @@ test('a contact message requires a name and message', function () {
|
|||||||
|
|
||||||
Queue::assertNotPushed(NotifyTelegramAboutContactMessage::class);
|
Queue::assertNotPushed(NotifyTelegramAboutContactMessage::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a page visit can be submitted and is queued for notification', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->withHeaders([
|
||||||
|
'User-Agent' => 'Pest Browser',
|
||||||
|
'Accept-Language' => 'nl-NL,nl;q=0.9',
|
||||||
|
])->postJson(route('page-visits.store'), [
|
||||||
|
'visitor_id' => 'visitor-123',
|
||||||
|
'url' => 'https://cv.robert.ooo/',
|
||||||
|
'path' => '/',
|
||||||
|
'title' => 'CV Roberto',
|
||||||
|
'referrer' => 'https://example.com',
|
||||||
|
'language' => 'nl-NL',
|
||||||
|
'timezone' => 'Europe/Amsterdam',
|
||||||
|
'screen' => [
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
],
|
||||||
|
'viewport' => [
|
||||||
|
'width' => 1440,
|
||||||
|
'height' => 900,
|
||||||
|
],
|
||||||
|
'device_pixel_ratio' => 1,
|
||||||
|
'color_depth' => 24,
|
||||||
|
'platform' => 'Linux x86_64',
|
||||||
|
'vendor' => 'Google Inc.',
|
||||||
|
'hardware_concurrency' => 8,
|
||||||
|
'device_memory' => 8,
|
||||||
|
'cookies_enabled' => true,
|
||||||
|
'online' => true,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'status' => 'queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::assertPushed(NotifyTelegramAboutPageVisit::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page visit requires a visitor id and valid url', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->postJson(route('page-visits.store'), [
|
||||||
|
'visitor_id' => '',
|
||||||
|
'url' => 'not-a-url',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['visitor_id', 'url']);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(NotifyTelegramAboutPageVisit::class);
|
||||||
|
});
|
||||||
|
|||||||
78
tests/Feature/TelegramNotificationServiceTest.php
Normal file
78
tests/Feature/TelegramNotificationServiceTest.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Personalia;
|
||||||
|
use App\Services\TelegramNotificationService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
test('telegram service sends a contact message notification', function () {
|
||||||
|
config([
|
||||||
|
'services.telegram.bot_token' => 'telegram-token',
|
||||||
|
'services.telegram.chat_id' => 'telegram-chat',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
app(TelegramNotificationService::class)->notifyContactMessage(
|
||||||
|
name: 'Roberto',
|
||||||
|
message: 'Hoi, ik wil graag contact opnemen.',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'Pest Browser',
|
||||||
|
email: 'roberto@example.com',
|
||||||
|
phone: '+31612345678'
|
||||||
|
);
|
||||||
|
|
||||||
|
Http::assertSent(fn ($request) => $request->url() === 'https://api.telegram.org/bottelegram-token/sendMessage'
|
||||||
|
&& $request['chat_id'] === 'telegram-chat'
|
||||||
|
&& str_contains($request['text'], 'Nieuw contactbericht ontvangen')
|
||||||
|
&& str_contains($request['text'], 'roberto@example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('telegram service sends a personalia click notification', function () {
|
||||||
|
config([
|
||||||
|
'services.telegram.bot_token' => 'telegram-token',
|
||||||
|
'services.telegram.chat_id' => 'telegram-chat',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$personalia = Personalia::factory()->create([
|
||||||
|
'value' => 'roberto@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(TelegramNotificationService::class)->notifyPersonaliaClick($personalia, '127.0.0.1', 'Pest Browser');
|
||||||
|
|
||||||
|
Http::assertSent(fn ($request) => str_contains($request['text'], 'Persoonlijke gegevens bekeken')
|
||||||
|
&& str_contains($request['text'], 'roberto@example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('telegram service sends a page visit notification', function () {
|
||||||
|
config([
|
||||||
|
'services.telegram.bot_token' => 'telegram-token',
|
||||||
|
'services.telegram.chat_id' => 'telegram-chat',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
app(TelegramNotificationService::class)->notifyPageVisit([
|
||||||
|
'visitor_id' => 'visitor-123',
|
||||||
|
'url' => 'https://cv.robert.ooo/',
|
||||||
|
'path' => '/',
|
||||||
|
'title' => 'CV Roberto',
|
||||||
|
'referrer' => 'https://example.com',
|
||||||
|
'language' => 'nl-NL',
|
||||||
|
'timezone' => 'Europe/Amsterdam',
|
||||||
|
'screen' => [
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
],
|
||||||
|
'viewport' => [
|
||||||
|
'width' => 1440,
|
||||||
|
'height' => 900,
|
||||||
|
],
|
||||||
|
], '127.0.0.1', 'Pest Browser', 'nl-NL,nl;q=0.9');
|
||||||
|
|
||||||
|
Http::assertSent(fn ($request) => str_contains($request['text'], 'Pagina bezocht')
|
||||||
|
&& str_contains($request['text'], 'Visitor ID: visitor-123')
|
||||||
|
&& str_contains($request['text'], 'Scherm: 1920x1080')
|
||||||
|
&& str_contains($request['text'], 'Viewport: 1440x900'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user