├── LICENSE.md ├── composer.json ├── database └── migrations │ └── 2024_10_14_000001_create_pan_analytics_table.php ├── resources └── js │ └── dist │ └── pan.iife.js ├── src ├── Actions │ └── CreateEvent.php ├── Adapters │ └── Laravel │ │ ├── Console │ │ └── Commands │ │ │ ├── InstallPanCommand.php │ │ │ ├── PanCommand.php │ │ │ └── PanFlushCommand.php │ │ ├── Http │ │ ├── Controllers │ │ │ └── EventController.php │ │ ├── Middleware │ │ │ └── InjectJavascriptLibrary.php │ │ └── Requests │ │ │ └── CreateEventRequest.php │ │ ├── Providers │ │ └── PanServiceProvider.php │ │ └── Repositories │ │ └── DatabaseAnalyticsRepository.php ├── Console │ └── Table.php ├── Contracts │ └── AnalyticsRepository.php ├── Enums │ └── EventType.php ├── PanConfiguration.php ├── Presentors │ └── AnalyticPresentor.php └── ValueObjects │ └── Analytic.php └── vite.config.ts /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Nuno Maduro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "panphp/pan", 3 | "description": "A simple, lightweight, and privacy-focused product analytics php package.", 4 | "keywords": [ 5 | "pan", 6 | "php", 7 | "analytics", 8 | "library", 9 | "laravel" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Nuno Maduro", 15 | "email": "enunomaduro@gmail.com" 16 | }, 17 | { 18 | "name": "David Hill", 19 | "email": "david@laravel.com" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.3.0" 24 | }, 25 | "conflict": { 26 | "laravel/framework": "<11.0.0" 27 | }, 28 | "require-dev": { 29 | "orchestra/testbench": "^9.5.2", 30 | "laravel/pint": "^1.18.1", 31 | "pestphp/pest": "^3.5.1", 32 | "pestphp/pest-plugin-type-coverage": "^3.1.0", 33 | "phpstan/phpstan": "^1.12.7", 34 | "rector/rector": "^1.2.8", 35 | "symfony/var-dumper": "^7.1.6" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Pan\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Tests\\": "tests/" 45 | } 46 | }, 47 | "minimum-stability": "stable", 48 | "prefer-stable": true, 49 | "config": { 50 | "sort-packages": true, 51 | "preferred-install": "dist", 52 | "allow-plugins": { 53 | "pestphp/pest-plugin": true 54 | } 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Pan\\Adapters\\Laravel\\Providers\\PanServiceProvider" 60 | ] 61 | } 62 | }, 63 | "scripts": { 64 | "refacto": "rector", 65 | "lint": "pint", 66 | "test:refacto": "rector --dry-run", 67 | "test:lint": "pint --test", 68 | "test:types": "phpstan analyse --ansi", 69 | "test:unit": "pest --colors=always --coverage --min=100", 70 | "test": [ 71 | "@test:refacto", 72 | "@test:lint", 73 | "@test:types", 74 | "@test:unit" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /database/migrations/2024_10_14_000001_create_pan_analytics_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | 18 | $table->unsignedBigInteger('impressions')->default(0); 19 | $table->unsignedBigInteger('hovers')->default(0); 20 | $table->unsignedBigInteger('clicks')->default(0); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('pan_analytics'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /resources/js/dist/pan.iife.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";window.__pan=window.__pan||{csrfToken:"%_PAN_CSRF_TOKEN_%",routePrefix:"%_PAN_ROUTE_PREFIX_%",observer:null,clickListener:null,mouseoverListener:null,inertiaStartListener:null},window.__pan.observer&&(window.__pan.observer.disconnect(),window.__pan.observer=null),window.__pan.clickListener&&(document.removeEventListener("click",window.__pan.clickListener),window.__pan.clickListener=null),window.__pan.mouseoverListener&&(document.removeEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.mouseoverListener=null),window.__pan.inertiaStartListener&&(document.removeEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.inertiaStartListener=null),function(){const p=e=>{const n=new MutationObserver(e);n.observe(document.body,{childList:!0,subtree:!0,attributes:!0}),window.__pan.observer=n};let i=[],a=null,r=[],s=[],u=[];const c=()=>{if(i.length===0)return;const e=i.slice();i=[],navigator.sendBeacon(`/${window.__pan.routePrefix}/events`,new Blob([JSON.stringify({events:e,_token:window.__pan.csrfToken})],{type:"application/json"}))},l=function(){a&&clearTimeout(a),a=setTimeout(c,1e3)},d=function(e,n){const w=e.target.closest("[data-pan]");if(w===null)return;const o=w.getAttribute("data-pan");if(o!==null){if(n==="hover"){if(s.includes(o))return;s.push(o)}if(n==="click"){if(u.includes(o))return;u.push(o)}i.push({type:n,name:o}),l()}},_=function(){document.querySelectorAll("[data-pan]").forEach(n=>{if(n.checkVisibility!==void 0&&!n.checkVisibility())return;const t=n.getAttribute("data-pan");t!==null&&(r.includes(t)||(r.push(t),i.push({type:"impression",name:t})))}),l()};p(function(){r.forEach(e=>{document.querySelector(`[data-pan='${e}']`)===null&&(r=r.filter(t=>t!==e),s=s.filter(t=>t!==e),u=u.filter(t=>t!==e))}),_()}),window.__pan.clickListener=e=>d(e,"click"),document.addEventListener("click",window.__pan.clickListener),window.__pan.mouseoverListener=e=>d(e,"hover"),document.addEventListener("mouseover",window.__pan.mouseoverListener),window.__pan.inertiaStartListener=e=>{r=[],s=[],u=[],_()},document.addEventListener("inertia:start",window.__pan.inertiaStartListener),window.__pan.beforeUnloadListener=function(e){i.length!==0&&c()},window.addEventListener("beforeunload",window.__pan.beforeUnloadListener)}()})(); 2 | -------------------------------------------------------------------------------- /src/Actions/CreateEvent.php: -------------------------------------------------------------------------------- 1 | repository->increment($name, $event); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Console/Commands/InstallPanCommand.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected $aliases = ['pan:install']; 29 | 30 | /** 31 | * The console command description. 32 | * 33 | * @var string 34 | */ 35 | protected $description = 'Install the Pan package'; 36 | 37 | /** 38 | * Execute the console command. 39 | */ 40 | public function handle(): void 41 | { 42 | $existingMigrations = glob(database_path('migrations/*_create_pan_analytics_table.php')); 43 | 44 | if ($existingMigrations === []) { 45 | $this->output->writeln(''); 46 | 47 | $this->components->task('Publishing Pan migrations', function (): void { 48 | $this->callSilent('vendor:publish', ['--tag' => 'pan-migrations']); 49 | }); 50 | 51 | if ($this->components->confirm('Would like to run the migrations now?')) { 52 | $this->call('migrate'); 53 | } 54 | } 55 | 56 | render(<<<'HTML' 57 | 58 |
59 | 62 |
63 |
64 | HTML, 65 | ); 66 | 67 | $this->components->info('Pan was installed successfully. You may start collecting analytics by adding the [data-pan="my-button"] attribute to your HTML elements. You can view analytics by running the [artisan pan] command.'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Console/Commands/PanCommand.php: -------------------------------------------------------------------------------- 1 | all(); 38 | 39 | if (is_string($filter = $this->option('filter'))) { 40 | $analytics = array_filter($analytics, fn (Analytic $analytic): bool => str_contains($analytic->name, $filter)); 41 | } 42 | 43 | if ($analytics === []) { 44 | $this->components->info('No analytics have been recorded yet. Get started collecting analytics by adding the [data-pan="my-button"] attribute to your HTML elements.'); 45 | 46 | return; 47 | } 48 | 49 | (new Table($this->output))->display( 50 | ['', 'Name', 'Impressions', 'Hovers', 'Clicks'], 51 | array_map(fn (Analytic $analytic): array => array_values($presentor->present($analytic)), $analytics) 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Console/Commands/PanFlushCommand.php: -------------------------------------------------------------------------------- 1 | flush(); 35 | 36 | $this->components->info('All analytics have been flushed.'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Http/Controllers/EventController.php: -------------------------------------------------------------------------------- 1 | $events */ 24 | $events = $request->collect('events'); 25 | 26 | $events->each(fn (array $event) => $action->handle($event['name'], EventType::from($event['type']))); 27 | 28 | return response()->noContent(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Http/Middleware/InjectJavascriptLibrary.php: -------------------------------------------------------------------------------- 1 | headers->get('Content-Type') === 'text/html; charset=UTF-8') { 37 | $content = (string) $response->getContent(); 38 | 39 | if (! str_contains($content, '') || ! str_contains($content, '')) { 40 | return $response; 41 | } 42 | 43 | $this->inject($response); 44 | } 45 | 46 | return $response; 47 | } 48 | 49 | /** 50 | * Inject the JavaScript library into the response. 51 | */ 52 | private function inject(Response $response): void 53 | { 54 | $original = $response->original ?? null; 55 | 56 | ['route_prefix' => $routePrefix] = $this->config->toArray(); 57 | 58 | $response->setContent( 59 | str_replace( 60 | '', 61 | sprintf(<<<'HTML' 62 | 65 | 66 | HTML, 67 | str_replace( 68 | ['%_PAN_CSRF_TOKEN_%', '%_PAN_ROUTE_PREFIX_%'], 69 | [(string) csrf_token(), $routePrefix], 70 | File::get(__DIR__.'/../../../../../resources/js/dist/pan.iife.js') 71 | ), 72 | ), 73 | (string) $response->getContent(), 74 | ) 75 | ); 76 | 77 | if ($original !== null) { 78 | $response->original = $original; // @phpstan-ignore-line 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Http/Requests/CreateEventRequest.php: -------------------------------------------------------------------------------- 1 | > 20 | */ 21 | public function rules(): array 22 | { 23 | return [ 24 | 'events' => ['required', 'array'], 25 | 'events.*.name' => ['required', 'string', 'alpha_dash:ascii'], 26 | 'events.*.type' => ['required', Rule::enum(EventType::class)], 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Providers/PanServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerConfiguration(); 30 | $this->registerRepositories(); 31 | } 32 | 33 | /** 34 | * Bootstrap any package services. 35 | */ 36 | public function boot(): void 37 | { 38 | $this->registerCommands(); 39 | $this->registerRoutes(); 40 | $this->registerPublishing(); 41 | } 42 | 43 | /** 44 | * Register the package configuration. 45 | */ 46 | private function registerConfiguration(): void 47 | { 48 | $this->app->bind(PanConfiguration::class, fn (): \Pan\PanConfiguration => PanConfiguration::instance()); 49 | } 50 | 51 | /** 52 | * Register the package repositories. 53 | */ 54 | private function registerRepositories(): void 55 | { 56 | $this->app->bind(AnalyticsRepository::class, DatabaseAnalyticsRepository::class); 57 | } 58 | 59 | /** 60 | * Register the package routes. 61 | */ 62 | private function registerRoutes(): void 63 | { 64 | /** @var \Illuminate\Foundation\Http\Kernel $kernel */ 65 | $kernel = $this->app->make(HttpContract::class); 66 | 67 | $kernel->pushMiddleware(InjectJavascriptLibrary::class); 68 | 69 | /** @var PanConfiguration $config */ 70 | $config = $this->app->get(PanConfiguration::class); 71 | 72 | Route::prefix($config->toArray()['route_prefix'])->group(function (): void { 73 | Route::post('/events', [EventController::class, 'store']); 74 | }); 75 | } 76 | 77 | /** 78 | * Register the package's publishable resources. 79 | */ 80 | private function registerPublishing(): void 81 | { 82 | if ($this->app->runningInConsole()) { 83 | $this->publishesMigrations([ 84 | __DIR__.'/../../../../database/migrations' => database_path('migrations'), 85 | ], 'pan-migrations'); 86 | } 87 | } 88 | 89 | /** 90 | * Register the package's commands. 91 | */ 92 | private function registerCommands(): void 93 | { 94 | if ($this->app->runningInConsole()) { 95 | $this->commands([ 96 | InstallPanCommand::class, 97 | PanCommand::class, 98 | PanFlushCommand::class, 99 | ]); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Repositories/DatabaseAnalyticsRepository.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | public function all(): array 32 | { 33 | /** @var array $all */ 34 | $all = DB::table('pan_analytics')->get()->map(fn (mixed $analytic): Analytic => new Analytic( 35 | id: (int) $analytic->id, // @phpstan-ignore-line 36 | name: $analytic->name, // @phpstan-ignore-line 37 | impressions: (int) $analytic->impressions, // @phpstan-ignore-line 38 | hovers: (int) $analytic->hovers, // @phpstan-ignore-line 39 | clicks: (int) $analytic->clicks, // @phpstan-ignore-line 40 | ))->toArray(); 41 | 42 | return $all; 43 | } 44 | 45 | /** 46 | * Increments the given event for the given analytic. 47 | */ 48 | public function increment(string $name, EventType $event): void 49 | { 50 | [ 51 | 'allowed_analytics' => $allowedAnalytics, 52 | 'max_analytics' => $maxAnalytics, 53 | ] = $this->config->toArray(); 54 | 55 | if (count($allowedAnalytics) > 0 && ! in_array($name, $allowedAnalytics, true)) { 56 | return; 57 | } 58 | 59 | if (DB::table('pan_analytics')->where('name', $name)->count() === 0) { 60 | if (DB::table('pan_analytics')->count() < $maxAnalytics) { 61 | DB::table('pan_analytics')->insert(['name' => $name, $event->column() => 1]); 62 | } 63 | 64 | return; 65 | } 66 | 67 | DB::table('pan_analytics')->where('name', $name)->increment($event->column()); 68 | } 69 | 70 | /** 71 | * Flush all analytics. 72 | */ 73 | public function flush(): void 74 | { 75 | DB::table('pan_analytics')->truncate(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Console/Table.php: -------------------------------------------------------------------------------- 1 | $headers 28 | * @param array> $rows 29 | */ 30 | public function display(array $headers, array $rows): void 31 | { 32 | $this->output->writeln(''); 33 | 34 | $table = new BaseTable($this->output); 35 | 36 | $table->setStyle('compact'); 37 | 38 | $table->setHeaders(array_map( 39 | fn ($header): string => " $header", 40 | $headers 41 | )); 42 | 43 | $table->setRows(array_map( 44 | fn ($row): array => array_map( 45 | fn ($cell): string => " $cell", 46 | $row 47 | ), 48 | $rows 49 | )); 50 | 51 | $table->render(); 52 | 53 | $this->output->writeln(''); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Contracts/AnalyticsRepository.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function all(): array; 21 | 22 | /** 23 | * Increments the given event for the given analytic. 24 | */ 25 | public function increment(string $name, EventType $event): void; 26 | 27 | /** 28 | * Flush all analytics. 29 | */ 30 | public function flush(): void; 31 | } 32 | -------------------------------------------------------------------------------- /src/Enums/EventType.php: -------------------------------------------------------------------------------- 1 | 'clicks', 23 | self::HOVER => 'hovers', 24 | self::IMPRESSION => 'impressions', 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/PanConfiguration.php: -------------------------------------------------------------------------------- 1 | $allowedAnalytics 18 | */ 19 | private function __construct( 20 | private int $maxAnalytics = 50, 21 | private array $allowedAnalytics = [], 22 | private string $routePrefix = 'pan', 23 | ) { 24 | // 25 | } 26 | 27 | /** 28 | * Returns the Pan configuration's instance. 29 | * 30 | * @internal 31 | */ 32 | public static function instance(): self 33 | { 34 | return self::$instance ??= new self; 35 | } 36 | 37 | /** 38 | * Sets the maximum number of analytics to store. 39 | * 40 | * @internal 41 | */ 42 | public function setMaxAnalytics(int $number): void 43 | { 44 | $this->maxAnalytics = $number; 45 | } 46 | 47 | /** 48 | * Sets the allowed analytics names to store. 49 | * 50 | * @param array $names 51 | * 52 | * @internal 53 | */ 54 | public function setAllowedAnalytics(array $names): void 55 | { 56 | $this->allowedAnalytics = $names; 57 | } 58 | 59 | /** 60 | * Sets the route prefix to be used. 61 | * 62 | * @internal 63 | */ 64 | public function setRoutePrefix(string $prefix): void 65 | { 66 | $this->routePrefix = $prefix; 67 | } 68 | 69 | /** 70 | * Sets the maximum number of analytics to store. 71 | */ 72 | public static function maxAnalytics(int $number): void 73 | { 74 | self::instance()->setMaxAnalytics($number); 75 | } 76 | 77 | /** 78 | * Sets the maximum number of analytics to store to unlimited. 79 | */ 80 | public static function unlimitedAnalytics(): void 81 | { 82 | self::instance()->setMaxAnalytics(PHP_INT_MAX); 83 | } 84 | 85 | /** 86 | * Sets the allowed analytics names to store. 87 | * 88 | * @param array $names 89 | */ 90 | public static function allowedAnalytics(array $names): void 91 | { 92 | self::instance()->setAllowedAnalytics($names); 93 | } 94 | 95 | /** 96 | * Sets the route prefix to be used. 97 | * 98 | * @internal 99 | */ 100 | public static function routePrefix(string $prefix): void 101 | { 102 | self::instance()->setRoutePrefix($prefix); 103 | } 104 | 105 | /** 106 | * Resets the configuration to its default values. 107 | * 108 | * @internal 109 | */ 110 | public static function reset(): void 111 | { 112 | self::maxAnalytics(50); 113 | self::allowedAnalytics([]); 114 | self::routePrefix('pan'); 115 | } 116 | 117 | /** 118 | * Converts the Pan configuration to an array. 119 | * 120 | * @return array{max_analytics: int, allowed_analytics: array, route_prefix: string} 121 | * 122 | * @internal 123 | */ 124 | public function toArray(): array 125 | { 126 | return [ 127 | 'max_analytics' => $this->maxAnalytics, 128 | 'allowed_analytics' => $this->allowedAnalytics, 129 | 'route_prefix' => $this->routePrefix, 130 | ]; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Presentors/AnalyticPresentor.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function present(Analytic $analytic): array 20 | { 21 | return [ 22 | 'id' => '#'.$analytic->id.'', 23 | 'name' => ''.$analytic->name.'', 24 | 'impressions' => $this->toHumanReadableNumber($analytic->impressions), 25 | 'hovers' => $this->toHumanReadableNumber($analytic->hovers).' ('.$this->toHumanReadablePercentage($analytic->impressions, $analytic->hovers).')', 26 | 'clicks' => $this->toHumanReadableNumber($analytic->clicks).' ('.$this->toHumanReadablePercentage($analytic->impressions, $analytic->clicks).')', 27 | ]; 28 | } 29 | 30 | /** 31 | * Returns a human-readable number. 32 | */ 33 | private function toHumanReadableNumber(int $number): string 34 | { 35 | return number_format($number); 36 | } 37 | 38 | /** 39 | * Returns a human-readable percentage. 40 | */ 41 | private function toHumanReadablePercentage(int $total, int $part): string 42 | { 43 | if ($total === 0) { 44 | return 'Infinity%'; 45 | } 46 | 47 | return number_format($part / $total * 100, 1).'%'; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ValueObjects/Analytic.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function __construct( 18 | public int $id, 19 | public string $name, 20 | public int $impressions, 21 | public int $hovers, 22 | public int $clicks, 23 | ) { 24 | // 25 | } 26 | 27 | /** 28 | * Returns the analytic as an array. 29 | * 30 | * @return array{id: int, name: string, impressions: int, hovers: int, clicks: int} 31 | */ 32 | public function toArray(): array 33 | { 34 | return [ 35 | 'id' => $this->id, 36 | 'name' => $this->name, 37 | 'impressions' => $this->impressions, 38 | 'hovers' => $this->hovers, 39 | 'clicks' => $this->clicks, 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | 4 | 5 | /** 6 | * @type {import('vite').UserConfig} 7 | */ 8 | export default defineConfig({ 9 | build: { 10 | outDir: 'resources/js/dist', 11 | lib: { 12 | entry: resolve(__dirname, 'resources/js/src/main.ts'), 13 | name: 'pan', 14 | fileName: 'pan', 15 | formats: ['iife'], 16 | } 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------