feat: add VisitorNickname class for deterministic visitor identification
Stripe-style memorable names (e.g. "Mossy Jackal") generated from IP hash. Includes behavior tag system based on page view history (cart-abandoner, demo-downloader, product-browser, returning, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
241
src/VisitorNickname.php
Normal file
241
src/VisitorNickname.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Visitor Nickname Generator
|
||||
*
|
||||
* Generates deterministic, memorable nicknames for visitors (Stripe-style)
|
||||
* and behavior tags based on session activity.
|
||||
*
|
||||
* @author mypresta.rocks
|
||||
* @copyright mypresta.rocks
|
||||
* @license Commercial License
|
||||
*/
|
||||
|
||||
namespace MyPrestaRocks\Session;
|
||||
|
||||
class VisitorNickname
|
||||
{
|
||||
/**
|
||||
* Adjective pool (100 words) — vivid, neutral, memorable.
|
||||
*/
|
||||
private const ADJECTIVES = [
|
||||
'amber', 'arctic', 'azure', 'blazing', 'bold',
|
||||
'bright', 'bronze', 'calm', 'cedar', 'charcoal',
|
||||
'clever', 'cobalt', 'copper', 'coral', 'cosmic',
|
||||
'crimson', 'crystal', 'daring', 'deft', 'dusky',
|
||||
'eager', 'electric', 'emerald', 'fading', 'fierce',
|
||||
'flint', 'frosty', 'gentle', 'gilded', 'glacial',
|
||||
'golden', 'granite', 'hazel', 'hidden', 'hollow',
|
||||
'hushed', 'iron', 'ivory', 'jade', 'keen',
|
||||
'lapis', 'lavender', 'lemon', 'lilac', 'lunar',
|
||||
'marble', 'marine', 'mellow', 'midnight', 'misty',
|
||||
'mossy', 'noble', 'obsidian', 'ochre', 'olive',
|
||||
'onyx', 'opal', 'orchid', 'pale', 'pearl',
|
||||
'phantom', 'pine', 'plum', 'polar', 'prism',
|
||||
'quartz', 'quiet', 'rapid', 'raven', 'roaming',
|
||||
'rose', 'ruby', 'rustic', 'sable', 'sage',
|
||||
'scarlet', 'shadow', 'silent', 'silver', 'slate',
|
||||
'solar', 'steel', 'storm', 'swift', 'tawny',
|
||||
'teal', 'terra', 'thorn', 'thunder', 'topaz',
|
||||
'twilight', 'umber', 'velvet', 'vermillion', 'violet',
|
||||
'vivid', 'walnut', 'wild', 'willow', 'zinc',
|
||||
];
|
||||
|
||||
/**
|
||||
* Noun pool (100 words) — animals + nature objects, easy to visualise.
|
||||
*/
|
||||
private const NOUNS = [
|
||||
'badger', 'bear', 'beetle', 'bison', 'bobcat',
|
||||
'brook', 'canyon', 'caribou', 'cedar', 'cheetah',
|
||||
'cliff', 'cobra', 'condor', 'coral', 'cougar',
|
||||
'crane', 'creek', 'crow', 'dagger', 'dawn',
|
||||
'deer', 'dingo', 'dove', 'drift', 'dusk',
|
||||
'eagle', 'ember', 'falcon', 'fern', 'finch',
|
||||
'flame', 'flint', 'fox', 'frost', 'gale',
|
||||
'gazelle', 'glacier', 'grove', 'gull', 'hare',
|
||||
'hawk', 'heron', 'hollow', 'horizon', 'hound',
|
||||
'ibis', 'jackal', 'jaguar', 'jay', 'kestrel',
|
||||
'lark', 'leaf', 'leopard', 'lynx', 'maple',
|
||||
'marsh', 'meadow', 'mesa', 'mist', 'moon',
|
||||
'moth', 'mountain', 'newt', 'oak', 'orca',
|
||||
'osprey', 'otter', 'owl', 'panther', 'peak',
|
||||
'pelican', 'pine', 'plover', 'pond', 'puma',
|
||||
'quail', 'raven', 'reef', 'ridge', 'robin',
|
||||
'salmon', 'shade', 'shark', 'sparrow', 'spider',
|
||||
'stag', 'stone', 'stork', 'storm', 'summit',
|
||||
'swift', 'thorn', 'tiger', 'trail', 'trout',
|
||||
'viper', 'warden', 'wolf', 'wren', 'zenith',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a deterministic nickname from an IP address.
|
||||
*
|
||||
* Same IP always produces the same name. The hash is stable
|
||||
* across PHP versions and platforms (crc32 + modulo).
|
||||
*
|
||||
* @param string $ip IP address (v4 or v6)
|
||||
* @return string e.g. "Vermillion Falcon"
|
||||
*/
|
||||
public static function generate(string $ip): string
|
||||
{
|
||||
// Use crc32 for speed — we just need distribution, not security
|
||||
$hash = crc32($ip);
|
||||
// Make it unsigned
|
||||
$hash = $hash & 0xFFFFFFFF;
|
||||
|
||||
$adjIdx = $hash % count(self::ADJECTIVES);
|
||||
$nounIdx = (int) floor($hash / count(self::ADJECTIVES)) % count(self::NOUNS);
|
||||
|
||||
return ucfirst(self::ADJECTIVES[$adjIdx]) . ' ' . ucfirst(self::NOUNS[$nounIdx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate behavior tags for a visitor based on their session data.
|
||||
*
|
||||
* @param array $sessionData Aggregated session row with keys:
|
||||
* - ip_address, total_pages, session_count, page_types (comma-separated list),
|
||||
* - has_cart, has_checkout, has_order, has_account, has_search, has_demo,
|
||||
* - landing_page_type, source_type, id_customer
|
||||
* @return array List of short tag strings, e.g. ['cart-abandoner', 'product-browser']
|
||||
*/
|
||||
public static function behaviorTags(array $d): array
|
||||
{
|
||||
$tags = [];
|
||||
|
||||
// Customer status
|
||||
if (!empty($d['id_customer'])) {
|
||||
$tags[] = 'customer';
|
||||
}
|
||||
|
||||
// Conversion funnel
|
||||
if (!empty($d['has_order'])) {
|
||||
$tags[] = 'buyer';
|
||||
} elseif (!empty($d['has_checkout'])) {
|
||||
$tags[] = 'checkout-started';
|
||||
} elseif (!empty($d['has_cart'])) {
|
||||
$tags[] = 'cart-abandoner';
|
||||
}
|
||||
|
||||
// Demo activity
|
||||
if (!empty($d['has_demo'])) {
|
||||
$tags[] = 'demo-downloader';
|
||||
}
|
||||
|
||||
// Browsing depth
|
||||
$pages = (int) ($d['total_pages'] ?? 0);
|
||||
if ($pages >= 10) {
|
||||
$tags[] = 'deep-browser';
|
||||
} elseif ($pages >= 5) {
|
||||
$tags[] = 'engaged';
|
||||
}
|
||||
|
||||
// Content affinity (from page_types string: comma-separated type IDs)
|
||||
$types = [];
|
||||
if (!empty($d['page_types'])) {
|
||||
$types = array_map('intval', explode(',', $d['page_types']));
|
||||
$types = array_unique($types);
|
||||
}
|
||||
|
||||
if (in_array(3, $types)) {
|
||||
$tags[] = 'product-browser';
|
||||
}
|
||||
if (in_array(9, $types)) {
|
||||
$tags[] = 'searcher';
|
||||
}
|
||||
if (in_array(4, $types)) {
|
||||
$tags[] = 'content-reader';
|
||||
}
|
||||
|
||||
// Returning visitor
|
||||
$sessions = (int) ($d['session_count'] ?? 0);
|
||||
if ($sessions >= 3) {
|
||||
$tags[] = 'returning';
|
||||
}
|
||||
|
||||
// First visit flag
|
||||
if (!empty($d['is_first_visit']) && $sessions <= 1) {
|
||||
$tags[] = 'first-visit';
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get behavior tags for a visitor by IP address (queries DB).
|
||||
*
|
||||
* @param string $ip
|
||||
* @param int $hoursBack How far back to look (default 72h)
|
||||
* @return array
|
||||
*/
|
||||
public static function getBehaviorTagsForIP(string $ip, int $hoursBack = 72): array
|
||||
{
|
||||
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
|
||||
$prefix = _DB_PREFIX_;
|
||||
|
||||
$row = $db->getRow(
|
||||
"SELECT
|
||||
s.ip_address,
|
||||
MAX(s.id_customer) as id_customer,
|
||||
SUM(s.pages_viewed) as total_pages,
|
||||
COUNT(*) as session_count,
|
||||
MAX(s.is_first_visit) as is_first_visit,
|
||||
GROUP_CONCAT(DISTINCT pv.page_type) as page_types,
|
||||
MAX(CASE WHEN pv.page_type = 5 THEN 1 ELSE 0 END) as has_cart,
|
||||
MAX(CASE WHEN pv.page_type = 6 THEN 1 ELSE 0 END) as has_checkout,
|
||||
MAX(CASE WHEN pv.page_type = 7 THEN 1 ELSE 0 END) as has_order,
|
||||
MAX(CASE WHEN pv.page_type = 8 THEN 1 ELSE 0 END) as has_account,
|
||||
MAX(CASE WHEN pv.page_type = 9 THEN 1 ELSE 0 END) as has_search,
|
||||
MAX(CASE WHEN pv.controller LIKE '%demo%' OR pv.controller LIKE '%Demo%' THEN 1 ELSE 0 END) as has_demo
|
||||
FROM `{$prefix}mpr_sessions` s
|
||||
LEFT JOIN `{$prefix}mpr_page_views` pv ON s.id_session = pv.id_session
|
||||
WHERE s.ip_address = '" . pSQL($ip) . "'
|
||||
AND s.date_add >= DATE_SUB(NOW(), INTERVAL {$hoursBack} HOUR)
|
||||
GROUP BY s.ip_address"
|
||||
);
|
||||
|
||||
if (!$row) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::behaviorTags($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render behavior tags as HTML badges.
|
||||
*
|
||||
* @param array $tags
|
||||
* @return string HTML
|
||||
*/
|
||||
public static function renderTagBadges(array $tags): string
|
||||
{
|
||||
if (empty($tags)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$colors = [
|
||||
'buyer' => '#10b981',
|
||||
'checkout-started' => '#f59e0b',
|
||||
'cart-abandoner' => '#ef4444',
|
||||
'demo-downloader' => '#8b5cf6',
|
||||
'deep-browser' => '#3b82f6',
|
||||
'engaged' => '#06b6d4',
|
||||
'product-browser' => '#6366f1',
|
||||
'searcher' => '#ec4899',
|
||||
'content-reader' => '#14b8a6',
|
||||
'returning' => '#f97316',
|
||||
'first-visit' => '#84cc16',
|
||||
'customer' => '#10b981',
|
||||
];
|
||||
|
||||
$html = '';
|
||||
foreach ($tags as $tag) {
|
||||
$color = $colors[$tag] ?? '#9ca3af';
|
||||
$html .= '<span style="display:inline-block;padding:1px 6px;border-radius:3px;'
|
||||
. 'font-size:10px;font-weight:600;line-height:16px;'
|
||||
. 'background:' . $color . '1a;color:' . $color . ';margin-right:3px;">'
|
||||
. $tag . '</span>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user