Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks failed
Node.js CI / build (push) Has been cancelled

# Conflicts:
#	src/lib/redis.ts
This commit is contained in:
Mike Cao 2026-02-22 23:28:12 -08:00
commit 580008cd09
105 changed files with 1944 additions and 646 deletions

View file

@ -67,7 +67,6 @@
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@umami/react-zen": "^0.245.0", "@umami/react-zen": "^0.245.0",
"@umami/redis-client": "^0.30.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@ -110,6 +109,7 @@
"react-simple-maps": "^2.3.0", "react-simple-maps": "^2.3.0",
"react-use-measure": "^2.0.4", "react-use-measure": "^2.0.4",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"redis": "^4.5.1",
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
"semver": "^7.7.4", "semver": "^7.7.4",
"serialize-error": "^12.0.0", "serialize-error": "^12.0.0",

16
pnpm-lock.yaml generated
View file

@ -44,9 +44,6 @@ importers:
'@umami/react-zen': '@umami/react-zen':
specifier: ^0.245.0 specifier: ^0.245.0
version: 0.245.0(@types/react@19.2.14)(immer@10.2.0)(react-aria-components@1.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.18)(use-sync-external-store@1.6.0(react@19.2.4)) version: 0.245.0(@types/react@19.2.14)(immer@10.2.0)(react-aria-components@1.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.18)(use-sync-external-store@1.6.0(react@19.2.4))
'@umami/redis-client':
specifier: ^0.30.0
version: 0.30.0
bcryptjs: bcryptjs:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.3 version: 3.0.3
@ -173,6 +170,9 @@ importers:
react-window: react-window:
specifier: ^1.8.6 specifier: ^1.8.6
version: 1.8.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.8.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
redis:
specifier: ^4.5.1
version: 4.7.1
request-ip: request-ip:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0 version: 3.3.0
@ -2965,9 +2965,6 @@ packages:
react-aria-components: ^1.0.0 react-aria-components: ^1.0.0
react-dom: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0
'@umami/redis-client@0.30.0':
resolution: {integrity: sha512-pqeMPdEFMH+9GDpiQd5MdRdTppif4vPl/sp8Y9hPY277g/UFlJAbCUJluESmZRBHjFdXtBPtLzQkxjvdjlRzuQ==}
acorn-walk@8.3.4: acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@ -10062,13 +10059,6 @@ snapshots:
- tailwindcss - tailwindcss
- use-sync-external-store - use-sync-external-store
'@umami/redis-client@0.30.0':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
redis: 4.7.1
transitivePeerDependencies:
- supports-color
acorn-walk@8.3.4: acorn-walk@8.3.4:
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0

View file

@ -51,6 +51,8 @@
"confirm": "تأكيد", "confirm": "تأكيد",
"confirm-password": "تأكيد كلمة المرور", "confirm-password": "تأكيد كلمة المرور",
"contains": "يحتوي على", "contains": "يحتوي على",
"regex-match": "يطابق التعبير النمطي",
"regex-not-match": "لا يطابق التعبير النمطي",
"content": "المحتوى", "content": "المحتوى",
"continue": "تابع", "continue": "تابع",
"conversion": "تحويل", "conversion": "تحويل",
@ -169,6 +171,9 @@
"manager": "مدير", "manager": "مدير",
"max": "الحد الأقصى", "max": "الحد الأقصى",
"maximize": "توسيع", "maximize": "توسيع",
"match": "تطابق",
"match-all": "الكل",
"match-any": "أي",
"medium": "وسيط", "medium": "وسيط",
"member": "عضو", "member": "عضو",
"members": "الأعضاء", "members": "الأعضاء",

View file

@ -51,6 +51,8 @@
"confirm": "Падцвердзіць", "confirm": "Падцвердзіць",
"confirm-password": "Падцвердзіць пароль", "confirm-password": "Падцвердзіць пароль",
"contains": "Уключае", "contains": "Уключае",
"regex-match": "Адпавядае рэгулярнаму выразу",
"regex-not-match": "Не адпавядае рэгулярнаму выразу",
"content": "Змест", "content": "Змест",
"continue": "Працягнуць", "continue": "Працягнуць",
"conversion": "Канверсія", "conversion": "Канверсія",
@ -169,6 +171,9 @@
"manager": "Кіраўнік", "manager": "Кіраўнік",
"max": "Максімум", "max": "Максімум",
"maximize": "Разгарнуць", "maximize": "Разгарнуць",
"match": "Адпаведнасць",
"match-all": "Усе",
"match-any": "Любы",
"medium": "Сярэдні", "medium": "Сярэдні",
"member": "Удзельнік", "member": "Удзельнік",
"members": "Удзельнікі", "members": "Удзельнікі",

View file

@ -51,6 +51,8 @@
"confirm": "Потвърди", "confirm": "Потвърди",
"confirm-password": "Потвърди парола", "confirm-password": "Потвърди парола",
"contains": "Съдържа", "contains": "Съдържа",
"regex-match": "Съвпада с регулярен израз",
"regex-not-match": "Не съвпада с регулярен израз",
"content": "Съдържание", "content": "Съдържание",
"continue": "Продължи", "continue": "Продължи",
"conversion": "Конверсия", "conversion": "Конверсия",
@ -169,6 +171,9 @@
"manager": "Мениджър", "manager": "Мениджър",
"max": "Максимум", "max": "Максимум",
"maximize": "Разшири", "maximize": "Разшири",
"match": "Съвпадение",
"match-all": "Всички",
"match-any": "Някои",
"medium": "Среден", "medium": "Среден",
"member": "Член", "member": "Член",
"members": "Членове", "members": "Членове",

View file

@ -51,6 +51,8 @@
"confirm": "নিশ্চিত করুন", "confirm": "নিশ্চিত করুন",
"confirm-password": "পাসওয়ার্ড নিশ্চিত করুন", "confirm-password": "পাসওয়ার্ড নিশ্চিত করুন",
"contains": "রয়েছে", "contains": "রয়েছে",
"regex-match": "রেজেক্সের সাথে মেলে",
"regex-not-match": "রেজেক্সের সাথে মেলে না",
"content": "বিষয়বস্তু", "content": "বিষয়বস্তু",
"continue": "পরবর্তিতে", "continue": "পরবর্তিতে",
"conversion": "রূপান্তর", "conversion": "রূপান্তর",
@ -169,6 +171,9 @@
"manager": "পরিচালক", "manager": "পরিচালক",
"max": "সর্বাধিক", "max": "সর্বাধিক",
"maximize": "বিস্তৃত করুন", "maximize": "বিস্তৃত করুন",
"match": "মিলান",
"match-all": "সব",
"match-any": "যেকোনো",
"medium": "মাঝারি", "medium": "মাঝারি",
"member": "সদস্য", "member": "সদস্য",
"members": "সদস্যগণ", "members": "সদস্যগণ",

View file

@ -51,6 +51,8 @@
"confirm": "Potvrdi", "confirm": "Potvrdi",
"confirm-password": "Potvrdi šifru", "confirm-password": "Potvrdi šifru",
"contains": "Sadrži", "contains": "Sadrži",
"regex-match": "Odgovara regularnom izrazu",
"regex-not-match": "Ne odgovara regularnom izrazu",
"content": "Sadržaj", "content": "Sadržaj",
"continue": "Nastavi", "continue": "Nastavi",
"conversion": "Konverzija", "conversion": "Konverzija",
@ -169,6 +171,9 @@
"manager": "Menadžer", "manager": "Menadžer",
"max": "Maks", "max": "Maks",
"maximize": "Proširi", "maximize": "Proširi",
"match": "Podudaranje",
"match-all": "Sve",
"match-any": "Bilo koje",
"medium": "Srednje", "medium": "Srednje",
"member": "Član", "member": "Član",
"members": "Članovi", "members": "Članovi",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"confirm-password": "Confirma la contrasenya", "confirm-password": "Confirma la contrasenya",
"contains": "Conté", "contains": "Conté",
"regex-match": "Coincideix amb l'expressió regular",
"regex-not-match": "No coincideix amb l'expressió regular",
"content": "Contingut", "content": "Contingut",
"continue": "Continuar", "continue": "Continuar",
"conversion": "Conversió", "conversion": "Conversió",
@ -169,6 +171,9 @@
"manager": "Responsable", "manager": "Responsable",
"max": "Màx", "max": "Màx",
"maximize": "Expandeix", "maximize": "Expandeix",
"match": "Coincidència",
"match-all": "Tots",
"match-any": "Qualsevol",
"medium": "Mitjà", "medium": "Mitjà",
"member": "Membre", "member": "Membre",
"members": "Membres", "members": "Membres",

View file

@ -51,6 +51,8 @@
"confirm": "Potvrdit", "confirm": "Potvrdit",
"confirm-password": "Potvrdit heslo", "confirm-password": "Potvrdit heslo",
"contains": "Obsahuje", "contains": "Obsahuje",
"regex-match": "Odpovídá regulárnímu výrazu",
"regex-not-match": "Neodpovídá regulárnímu výrazu",
"content": "Obsah", "content": "Obsah",
"continue": "Pokračovat", "continue": "Pokračovat",
"conversion": "Konverze", "conversion": "Konverze",
@ -169,6 +171,9 @@
"manager": "Správce", "manager": "Správce",
"max": "Max", "max": "Max",
"maximize": "Rozbalit", "maximize": "Rozbalit",
"match": "Shoda",
"match-all": "Vše",
"match-any": "Jakýkoli",
"medium": "Střední", "medium": "Střední",
"member": "Člen", "member": "Člen",
"members": "Členové", "members": "Členové",

View file

@ -51,6 +51,8 @@
"confirm": "Bekræft", "confirm": "Bekræft",
"confirm-password": "Godkendt adgangskode", "confirm-password": "Godkendt adgangskode",
"contains": "Indeholder", "contains": "Indeholder",
"regex-match": "Matcher regulært udtryk",
"regex-not-match": "Matcher ikke regulært udtryk",
"content": "Indhold", "content": "Indhold",
"continue": "Fortsæt", "continue": "Fortsæt",
"conversion": "Konvertering", "conversion": "Konvertering",
@ -169,6 +171,9 @@
"manager": "Leder", "manager": "Leder",
"max": "Maks", "max": "Maks",
"maximize": "Udvid", "maximize": "Udvid",
"match": "Match",
"match-all": "Alle",
"match-any": "Enhver",
"medium": "Medie", "medium": "Medie",
"member": "Medlem", "member": "Medlem",
"members": "Medlemmer", "members": "Medlemmer",

View file

@ -51,6 +51,8 @@
"confirm": "Bestätige", "confirm": "Bestätige",
"confirm-password": "Passwort widerhole", "confirm-password": "Passwort widerhole",
"contains": "Enthaltet", "contains": "Enthaltet",
"regex-match": "Entspricht regulärem Ausdruck",
"regex-not-match": "Entspricht nicht regulärem Ausdruck",
"content": "Inhalt", "content": "Inhalt",
"continue": "Wiiter", "continue": "Wiiter",
"conversion": "Umwandlig", "conversion": "Umwandlig",
@ -169,6 +171,9 @@
"manager": "Verwalter", "manager": "Verwalter",
"max": "Max", "max": "Max",
"maximize": "Uusklappe", "maximize": "Uusklappe",
"match": "Übereinstimmung",
"match-all": "Alle",
"match-any": "Beliebige",
"medium": "Medium", "medium": "Medium",
"member": "Mitglied", "member": "Mitglied",
"members": "Mitglieder", "members": "Mitglieder",

View file

@ -51,6 +51,8 @@
"confirm": "Bestätigen", "confirm": "Bestätigen",
"confirm-password": "Passwort wiederholen", "confirm-password": "Passwort wiederholen",
"contains": "Enthält", "contains": "Enthält",
"regex-match": "Entspricht regulärem Ausdruck",
"regex-not-match": "Entspricht nicht regulärem Ausdruck",
"content": "Inhalt", "content": "Inhalt",
"continue": "Weiter", "continue": "Weiter",
"conversion": "Konversion", "conversion": "Konversion",
@ -169,6 +171,9 @@
"manager": "Verwaltung", "manager": "Verwaltung",
"max": "Max", "max": "Max",
"maximize": "Erweitern", "maximize": "Erweitern",
"match": "Übereinstimmung",
"match-all": "Alle",
"match-any": "Beliebige",
"medium": "Medium", "medium": "Medium",
"member": "Mitglied", "member": "Mitglied",
"members": "Mitglieder", "members": "Mitglieder",

View file

@ -51,6 +51,8 @@
"confirm": "Επιβεβαίωση", "confirm": "Επιβεβαίωση",
"confirm-password": "Επιβεβαίωση κωδικού", "confirm-password": "Επιβεβαίωση κωδικού",
"contains": "Περιέχει", "contains": "Περιέχει",
"regex-match": "Ταιριάζει με κανονική έκφραση",
"regex-not-match": "Δεν ταιριάζει με κανονική έκφραση",
"content": "Περιεχόμενο", "content": "Περιεχόμενο",
"continue": "Συνέχεια", "continue": "Συνέχεια",
"conversion": "Μετατροπή", "conversion": "Μετατροπή",
@ -169,6 +171,9 @@
"manager": "Διαχειριστής", "manager": "Διαχειριστής",
"max": "Μέγ", "max": "Μέγ",
"maximize": "Expand", "maximize": "Expand",
"match": "Αντιστοίχιση",
"match-all": "Όλα",
"match-any": "Οποιοδήποτε",
"medium": "Μέσο", "medium": "Μέσο",
"member": "Μέλος", "member": "Μέλος",
"members": "Μέλη", "members": "Μέλη",

View file

@ -51,6 +51,8 @@
"confirm": "Confirm", "confirm": "Confirm",
"confirm-password": "Confirm password", "confirm-password": "Confirm password",
"contains": "Contains", "contains": "Contains",
"regex-match": "Matches regex",
"regex-not-match": "Does not match regex",
"content": "Content", "content": "Content",
"continue": "Continue", "continue": "Continue",
"conversion": "Conversion", "conversion": "Conversion",
@ -169,6 +171,9 @@
"manager": "Manager", "manager": "Manager",
"max": "Max", "max": "Max",
"maximize": "Expand", "maximize": "Expand",
"match": "Match",
"match-all": "All",
"match-any": "Any",
"medium": "Medium", "medium": "Medium",
"member": "Member", "member": "Member",
"members": "Members", "members": "Members",

View file

@ -51,6 +51,8 @@
"confirm": "Confirm", "confirm": "Confirm",
"confirm-password": "Confirm password", "confirm-password": "Confirm password",
"contains": "Contains", "contains": "Contains",
"regex-match": "Matches regex",
"regex-not-match": "Does not match regex",
"content": "Content", "content": "Content",
"continue": "Continue", "continue": "Continue",
"conversion": "Conversion", "conversion": "Conversion",
@ -168,6 +170,9 @@
"manage": "Manage", "manage": "Manage",
"manager": "Manager", "manager": "Manager",
"max": "Max", "max": "Max",
"match": "Match",
"match-all": "All",
"match-any": "Any",
"maximize": "Maximize", "maximize": "Maximize",
"medium": "Medium", "medium": "Medium",
"member": "Member", "member": "Member",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"confirm-password": "Confirmar contraseña", "confirm-password": "Confirmar contraseña",
"contains": "Contiene", "contains": "Contiene",
"regex-match": "Coincide con expresión regular",
"regex-not-match": "No coincide con expresión regular",
"content": "Contenido", "content": "Contenido",
"continue": "Continuar", "continue": "Continuar",
"conversion": "Conversión", "conversion": "Conversión",
@ -169,6 +171,9 @@
"manager": "Gerente", "manager": "Gerente",
"max": "Máximo", "max": "Máximo",
"maximize": "Expandir", "maximize": "Expandir",
"match": "Coincidencia",
"match-all": "Todo",
"match-any": "Cualquiera",
"medium": "Medio", "medium": "Medio",
"member": "Miembro", "member": "Miembro",
"members": "Miembros", "members": "Miembros",

View file

@ -51,6 +51,8 @@
"confirm": "تأیید", "confirm": "تأیید",
"confirm-password": "تأیید رمز", "confirm-password": "تأیید رمز",
"contains": "شامل", "contains": "شامل",
"regex-match": "با عبارت منظم مطابقت دارد",
"regex-not-match": "با عبارت منظم مطابقت ندارد",
"content": "محتوا", "content": "محتوا",
"continue": "ادامه", "continue": "ادامه",
"conversion": "تبدیل", "conversion": "تبدیل",
@ -169,6 +171,9 @@
"manager": "مدیر", "manager": "مدیر",
"max": "حداکثر", "max": "حداکثر",
"maximize": "گسترش", "maximize": "گسترش",
"match": "تطابق",
"match-all": "همه",
"match-any": "هر",
"medium": "متوسط", "medium": "متوسط",
"member": "عضو", "member": "عضو",
"members": "اعضا", "members": "اعضا",

View file

@ -51,6 +51,8 @@
"confirm": "Vahvista", "confirm": "Vahvista",
"confirm-password": "Vahvista salasana", "confirm-password": "Vahvista salasana",
"contains": "Sisältää", "contains": "Sisältää",
"regex-match": "Vastaa säännöllistä lauseketta",
"regex-not-match": "Ei vastaa säännöllistä lauseketta",
"content": "Sisältö", "content": "Sisältö",
"continue": "Jatka", "continue": "Jatka",
"conversion": "Konversio", "conversion": "Konversio",
@ -169,6 +171,9 @@
"manager": "Päällikkö", "manager": "Päällikkö",
"max": "Maksimi", "max": "Maksimi",
"maximize": "Laajenna", "maximize": "Laajenna",
"match": "Vastaavuus",
"match-all": "Kaikki",
"match-any": "Mikä tahansa",
"medium": "Keskitaso", "medium": "Keskitaso",
"member": "Jäsen", "member": "Jäsen",
"members": "Jäsenet", "members": "Jäsenet",

View file

@ -51,6 +51,8 @@
"confirm": "Staðfest", "confirm": "Staðfest",
"confirm-password": "Vátta loyniorð", "confirm-password": "Vátta loyniorð",
"contains": "Inniheldur", "contains": "Inniheldur",
"regex-match": "Samsvarar við regluligan úttrykk",
"regex-not-match": "Samsvarar ikki við regluligan úttrykk",
"content": "Innihald", "content": "Innihald",
"continue": "Halt fram", "continue": "Halt fram",
"conversion": "Umvending", "conversion": "Umvending",
@ -169,6 +171,9 @@
"manager": "Stjóri", "manager": "Stjóri",
"max": "Mest", "max": "Mest",
"maximize": "Víðka", "maximize": "Víðka",
"match": "Samsvørun",
"match-all": "Allt",
"match-any": "Nakað",
"medium": "Miðal", "medium": "Miðal",
"member": "Limur", "member": "Limur",
"members": "Limir", "members": "Limir",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"confirm-password": "Confirmation du mot de passe", "confirm-password": "Confirmation du mot de passe",
"contains": "Contient", "contains": "Contient",
"regex-match": "Correspond à l'expression régulière",
"regex-not-match": "Ne correspond pas à l'expression régulière",
"content": "Contenu", "content": "Contenu",
"continue": "Continuer", "continue": "Continuer",
"conversion": "Conversion", "conversion": "Conversion",
@ -169,6 +171,9 @@
"manager": "Gestionnaire", "manager": "Gestionnaire",
"max": "Max", "max": "Max",
"maximize": "Développer", "maximize": "Développer",
"match": "Correspondance",
"match-all": "Tous",
"match-any": "N'importe lequel",
"medium": "Moyen", "medium": "Moyen",
"member": "Membre", "member": "Membre",
"members": "Membres", "members": "Membres",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"confirm-password": "Confirmar contrasinal", "confirm-password": "Confirmar contrasinal",
"contains": "Contén", "contains": "Contén",
"regex-match": "Meaitseálann sé le slonn rialta",
"regex-not-match": "Ní mheaitseálann sé le slonn rialta",
"content": "Contido", "content": "Contido",
"continue": "Continuar", "continue": "Continuar",
"conversion": "Conversión", "conversion": "Conversión",
@ -169,6 +171,9 @@
"manager": "Xestor", "manager": "Xestor",
"max": "Máx", "max": "Máx",
"maximize": "Expandir", "maximize": "Expandir",
"match": "Coincidencia",
"match-all": "Todo",
"match-any": "Calquera",
"medium": "Medio", "medium": "Medio",
"member": "Membro", "member": "Membro",
"members": "Membros", "members": "Membros",

View file

@ -51,6 +51,8 @@
"confirm": "אשר", "confirm": "אשר",
"confirm-password": "אישור סיסמה", "confirm-password": "אישור סיסמה",
"contains": "מכיל", "contains": "מכיל",
"regex-match": "תואם ביטוי רגולרי",
"regex-not-match": "לא תואם ביטוי רגולרי",
"content": "תוכן", "content": "תוכן",
"continue": "המשך", "continue": "המשך",
"conversion": "המרה", "conversion": "המרה",
@ -169,6 +171,9 @@
"manager": "מנהל", "manager": "מנהל",
"max": "מקסימום", "max": "מקסימום",
"maximize": "הרחב", "maximize": "הרחב",
"match": "התאמה",
"match-all": "הכל",
"match-any": "כלשהו",
"medium": "בינוני", "medium": "בינוני",
"member": "חבר", "member": "חבר",
"members": "חברים", "members": "חברים",

View file

@ -51,6 +51,8 @@
"confirm": "पुष्टि करें", "confirm": "पुष्टि करें",
"confirm-password": "पासवर्ड की पुष्टि कीजिये", "confirm-password": "पासवर्ड की पुष्टि कीजिये",
"contains": "शामिल है", "contains": "शामिल है",
"regex-match": "रेगेक्स से मेल खाता है",
"regex-not-match": "रेगेक्स से मेल नहीं खाता",
"content": "सामग्री", "content": "सामग्री",
"continue": "जारी रखें", "continue": "जारी रखें",
"conversion": "रूपांतरण", "conversion": "रूपांतरण",
@ -169,6 +171,9 @@
"manager": "प्रबंधक", "manager": "प्रबंधक",
"max": "अधिकतम", "max": "अधिकतम",
"maximize": "विस्तार करें", "maximize": "विस्तार करें",
"match": "मेल",
"match-all": "सभी",
"match-any": "कोई भी",
"medium": "मध्यम", "medium": "मध्यम",
"member": "सदस्य", "member": "सदस्य",
"members": "सदस्यगण", "members": "सदस्यगण",

View file

@ -51,6 +51,8 @@
"confirm": "Potvrdi", "confirm": "Potvrdi",
"confirm-password": "Potvrdi lozinku", "confirm-password": "Potvrdi lozinku",
"contains": "Sadrži", "contains": "Sadrži",
"regex-match": "Odgovara regularnom izrazu",
"regex-not-match": "Ne odgovara regularnom izrazu",
"content": "Sadržaj", "content": "Sadržaj",
"continue": "Nastavi", "continue": "Nastavi",
"conversion": "Konverzija", "conversion": "Konverzija",
@ -169,6 +171,9 @@
"manager": "Upravitelj", "manager": "Upravitelj",
"max": "Maksimum", "max": "Maksimum",
"maximize": "Proširi", "maximize": "Proširi",
"match": "Podudaranje",
"match-all": "Sve",
"match-any": "Bilo koje",
"medium": "Srednje", "medium": "Srednje",
"member": "Član", "member": "Član",
"members": "Članovi", "members": "Članovi",

View file

@ -51,6 +51,8 @@
"confirm": "Megerősít", "confirm": "Megerősít",
"confirm-password": "Jelszó megerősítése", "confirm-password": "Jelszó megerősítése",
"contains": "Tartalmazza", "contains": "Tartalmazza",
"regex-match": "Illeszkedik a reguláris kifejezésre",
"regex-not-match": "Nem illeszkedik a reguláris kifejezésre",
"content": "Tartalom", "content": "Tartalom",
"continue": "Folytatás", "continue": "Folytatás",
"conversion": "Konverzió", "conversion": "Konverzió",
@ -169,6 +171,9 @@
"manager": "Menedzser", "manager": "Menedzser",
"max": "Maximum", "max": "Maximum",
"maximize": "Kibontás", "maximize": "Kibontás",
"match": "Egyezés",
"match-all": "Összes",
"match-any": "Bármelyik",
"medium": "Közepes", "medium": "Közepes",
"member": "Tag", "member": "Tag",
"members": "Tagok", "members": "Tagok",

View file

@ -51,6 +51,8 @@
"confirm": "Konfirmasi", "confirm": "Konfirmasi",
"confirm-password": "Konfirmasi kata sandi", "confirm-password": "Konfirmasi kata sandi",
"contains": "Mengandung", "contains": "Mengandung",
"regex-match": "Cocok dengan ekspresi reguler",
"regex-not-match": "Tidak cocok dengan ekspresi reguler",
"content": "Konten", "content": "Konten",
"continue": "Lanjutkan", "continue": "Lanjutkan",
"conversion": "Konversi", "conversion": "Konversi",
@ -169,6 +171,9 @@
"manager": "Manajer", "manager": "Manajer",
"max": "Maksimum", "max": "Maksimum",
"maximize": "Perluas", "maximize": "Perluas",
"match": "Cocok",
"match-all": "Semua",
"match-any": "Salah satu",
"medium": "Sedang", "medium": "Sedang",
"member": "Anggota", "member": "Anggota",
"members": "Anggota", "members": "Anggota",

View file

@ -51,6 +51,8 @@
"confirm": "Conferma", "confirm": "Conferma",
"confirm-password": "Conferma password", "confirm-password": "Conferma password",
"contains": "Contiene", "contains": "Contiene",
"regex-match": "Corrisponde all'espressione regolare",
"regex-not-match": "Non corrisponde all'espressione regolare",
"content": "Contenuto", "content": "Contenuto",
"continue": "Continua", "continue": "Continua",
"conversion": "Conversione", "conversion": "Conversione",
@ -169,6 +171,9 @@
"manager": "Gestore", "manager": "Gestore",
"max": "Massimo", "max": "Massimo",
"maximize": "Espandi", "maximize": "Espandi",
"match": "Corrispondenza",
"match-all": "Tutti",
"match-any": "Qualsiasi",
"medium": "Medio", "medium": "Medio",
"member": "Membro", "member": "Membro",
"members": "Membri", "members": "Membri",

View file

@ -51,6 +51,8 @@
"confirm": "確認", "confirm": "確認",
"confirm-password": "パスワード(確認)", "confirm-password": "パスワード(確認)",
"contains": "コンテンツ", "contains": "コンテンツ",
"regex-match": "正規表現に一致",
"regex-not-match": "正規表現に一致しない",
"content": "コンテンツ", "content": "コンテンツ",
"continue": "続ける", "continue": "続ける",
"conversion": "コンバージョン", "conversion": "コンバージョン",
@ -169,6 +171,9 @@
"manager": "管理者", "manager": "管理者",
"max": "最大", "max": "最大",
"maximize": "展開", "maximize": "展開",
"match": "一致",
"match-all": "すべて",
"match-any": "いずれか",
"medium": "メディア", "medium": "メディア",
"member": "メンバー", "member": "メンバー",
"members": "メンバー", "members": "メンバー",

View file

@ -51,6 +51,8 @@
"confirm": "បញ្ជាក់", "confirm": "បញ្ជាក់",
"confirm-password": "បញ្ជាក់ពាក្យសម្ងាត់", "confirm-password": "បញ្ជាក់ពាក្យសម្ងាត់",
"contains": "មាន", "contains": "មាន",
"regex-match": "ត្រូវនឹងកន្សោមធម្មតា",
"regex-not-match": "មិនត្រូវនឹងកន្សោមធម្មតា",
"content": "មាតិកា", "content": "មាតិកា",
"continue": "បន្ត", "continue": "បន្ត",
"conversion": "ការបម្លែង", "conversion": "ការបម្លែង",
@ -169,6 +171,9 @@
"manager": "អ្នកគ្រប់គ្រង", "manager": "អ្នកគ្រប់គ្រង",
"max": "អតិបរមា", "max": "អតិបរមា",
"maximize": "ពង្រីក", "maximize": "ពង្រីក",
"match": "ត្រូវគ្នា",
"match-all": "ទាំងអស់",
"match-any": "ណាមួយ",
"medium": "មធ្យម", "medium": "មធ្យម",
"member": "សមាជិក", "member": "សមាជិក",
"members": "សមាជិក", "members": "សមាជិក",

View file

@ -51,6 +51,8 @@
"confirm": "확인", "confirm": "확인",
"confirm-password": "비밀번호 확인", "confirm-password": "비밀번호 확인",
"contains": "포함", "contains": "포함",
"regex-match": "정규식과 일치",
"regex-not-match": "정규식과 일치하지 않음",
"content": "콘텐츠", "content": "콘텐츠",
"continue": "계속", "continue": "계속",
"conversion": "전환", "conversion": "전환",
@ -169,6 +171,9 @@
"manager": "관리자", "manager": "관리자",
"max": "최대", "max": "최대",
"maximize": "확장", "maximize": "확장",
"match": "일치",
"match-all": "전체",
"match-any": "일부",
"medium": "미디엄", "medium": "미디엄",
"member": "멤버", "member": "멤버",
"members": "멤버", "members": "멤버",

View file

@ -51,6 +51,8 @@
"confirm": "Patvirtinti", "confirm": "Patvirtinti",
"confirm-password": "Patvirtinti slaptažodį", "confirm-password": "Patvirtinti slaptažodį",
"contains": "Turi", "contains": "Turi",
"regex-match": "Atitinka reguliariąją išraišką",
"regex-not-match": "Neatitinka reguliariosios išraiškos",
"content": "Turinys", "content": "Turinys",
"continue": "Tęsti", "continue": "Tęsti",
"conversion": "Konversija", "conversion": "Konversija",
@ -169,6 +171,9 @@
"manager": "Vadovas", "manager": "Vadovas",
"max": "Maksimumas", "max": "Maksimumas",
"maximize": "Išplėsti", "maximize": "Išplėsti",
"match": "Atitikimas",
"match-all": "Visi",
"match-any": "Bet kuris",
"medium": "Vidutinis", "medium": "Vidutinis",
"member": "Narys", "member": "Narys",
"members": "Nariai", "members": "Nariai",

View file

@ -51,6 +51,8 @@
"confirm": "Батлах", "confirm": "Батлах",
"confirm-password": "Шинэ нууц үгээ давтах", "confirm-password": "Шинэ нууц үгээ давтах",
"contains": "Агуулах", "contains": "Агуулах",
"regex-match": "Регекс-тэй таарна",
"regex-not-match": "Регекс-тэй таарахгүй",
"content": "Агуулга", "content": "Агуулга",
"continue": "Үргэлжлүүлэх", "continue": "Үргэлжлүүлэх",
"conversion": "Хөрвүүлэлт", "conversion": "Хөрвүүлэлт",
@ -169,6 +171,9 @@
"manager": "Удирдагч", "manager": "Удирдагч",
"max": "Дээд", "max": "Дээд",
"maximize": "Өргөтгөх", "maximize": "Өргөтгөх",
"match": "Тохирох",
"match-all": "Бүгд",
"match-any": "Ямар ч",
"medium": "Дунд", "medium": "Дунд",
"member": "Гишүүн", "member": "Гишүүн",
"members": "Гишүүд", "members": "Гишүүд",

View file

@ -51,6 +51,8 @@
"confirm": "Sahkan", "confirm": "Sahkan",
"confirm-password": "Sahkan kata laluan", "confirm-password": "Sahkan kata laluan",
"contains": "Mengandungi", "contains": "Mengandungi",
"regex-match": "Sepadan dengan ungkapan biasa",
"regex-not-match": "Tidak sepadan dengan ungkapan biasa",
"content": "Kandungan", "content": "Kandungan",
"continue": "Teruskan", "continue": "Teruskan",
"conversion": "Penukaran", "conversion": "Penukaran",
@ -169,6 +171,9 @@
"manager": "Pengurus", "manager": "Pengurus",
"max": "Maks", "max": "Maks",
"maximize": "Expand", "maximize": "Expand",
"match": "Padanan",
"match-all": "Semua",
"match-any": "Mana-mana",
"medium": "Medium", "medium": "Medium",
"member": "Ahli", "member": "Ahli",
"members": "Ahli", "members": "Ahli",

View file

@ -51,6 +51,8 @@
"confirm": "အတည်ပြုသည်", "confirm": "အတည်ပြုသည်",
"confirm-password": "စကားဝှက်အတည်ပြုသည်", "confirm-password": "စကားဝှက်အတည်ပြုသည်",
"contains": "ပါဝင်သည်", "contains": "ပါဝင်သည်",
"regex-match": "Regex နှင့် ကိုက်ညီသည်",
"regex-not-match": "Regex နှင့် မကိုက်ညီပါ",
"content": "အကြောင်းအရာ", "content": "အကြောင်းအရာ",
"continue": "ဆက်သွားမည်", "continue": "ဆက်သွားမည်",
"conversion": "ပြောင်းလဲမှု", "conversion": "ပြောင်းလဲမှု",
@ -169,6 +171,9 @@
"manager": "မန်နေဂျာ", "manager": "မန်နေဂျာ",
"max": "အများဆုံး", "max": "အများဆုံး",
"maximize": "Expand", "maximize": "Expand",
"match": "ကိုက်ညီ",
"match-all": "အားလုံး",
"match-any": "တစ်ခုခု",
"medium": "မီဒီယမ်", "medium": "မီဒီယမ်",
"member": "အဖွဲ့ဝင်", "member": "အဖွဲ့ဝင်",
"members": "အဖွဲ့ဝင်များ", "members": "အဖွဲ့ဝင်များ",

View file

@ -51,6 +51,8 @@
"confirm": "Bekreft", "confirm": "Bekreft",
"confirm-password": "Godkjenn passord", "confirm-password": "Godkjenn passord",
"contains": "Inneholder", "contains": "Inneholder",
"regex-match": "Samsvarer med regulært uttrykk",
"regex-not-match": "Samsvarer ikke med regulært uttrykk",
"content": "Innhold", "content": "Innhold",
"continue": "Fortsett", "continue": "Fortsett",
"conversion": "Konvertering", "conversion": "Konvertering",
@ -169,6 +171,9 @@
"manager": "Administrator", "manager": "Administrator",
"max": "Maks", "max": "Maks",
"maximize": "Utvid", "maximize": "Utvid",
"match": "Samsvar",
"match-all": "Alle",
"match-any": "Enhver",
"medium": "Medium", "medium": "Medium",
"member": "Bruker", "member": "Bruker",
"members": "Brukere", "members": "Brukere",

View file

@ -51,6 +51,8 @@
"confirm": "Bevestigen", "confirm": "Bevestigen",
"confirm-password": "Wachtwoord bevestigen", "confirm-password": "Wachtwoord bevestigen",
"contains": "Bevat", "contains": "Bevat",
"regex-match": "Komt overeen met reguliere expressie",
"regex-not-match": "Komt niet overeen met reguliere expressie",
"content": "Inhoud", "content": "Inhoud",
"continue": "Doorgaan", "continue": "Doorgaan",
"conversion": "Conversie", "conversion": "Conversie",
@ -169,6 +171,9 @@
"manager": "Beheerder", "manager": "Beheerder",
"max": "Max", "max": "Max",
"maximize": "Uitvouwen", "maximize": "Uitvouwen",
"match": "Overeenkomst",
"match-all": "Alle",
"match-any": "Elk",
"medium": "Medium", "medium": "Medium",
"member": "Gebruiker", "member": "Gebruiker",
"members": "Gebruikers", "members": "Gebruikers",

View file

@ -51,6 +51,8 @@
"confirm": "Potwierdź", "confirm": "Potwierdź",
"confirm-password": "Potwierdź hasło", "confirm-password": "Potwierdź hasło",
"contains": "Zawiera", "contains": "Zawiera",
"regex-match": "Pasuje do wyrażenia regularnego",
"regex-not-match": "Nie pasuje do wyrażenia regularnego",
"content": "Treść", "content": "Treść",
"continue": "Kontynuuj", "continue": "Kontynuuj",
"conversion": "Konwersja", "conversion": "Konwersja",
@ -169,6 +171,9 @@
"manager": "Menedżer", "manager": "Menedżer",
"max": "Maks", "max": "Maks",
"maximize": "Rozwiń", "maximize": "Rozwiń",
"match": "Dopasowanie",
"match-all": "Wszystkie",
"match-any": "Dowolny",
"medium": "Medium", "medium": "Medium",
"member": "Członek", "member": "Członek",
"members": "Członkowie", "members": "Członkowie",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"confirm-password": "Confirmar senha", "confirm-password": "Confirmar senha",
"contains": "Contém", "contains": "Contém",
"regex-match": "Corresponde à expressão regular",
"regex-not-match": "Não corresponde à expressão regular",
"content": "Conteúdo", "content": "Conteúdo",
"continue": "Continuar", "continue": "Continuar",
"conversion": "Conversão", "conversion": "Conversão",
@ -169,6 +171,9 @@
"manager": "Gerente", "manager": "Gerente",
"max": "Máximo", "max": "Máximo",
"maximize": "Expandir", "maximize": "Expandir",
"match": "Correspondência",
"match-all": "Todos",
"match-any": "Qualquer",
"medium": "Médio", "medium": "Médio",
"member": "Membro", "member": "Membro",
"members": "Membros", "members": "Membros",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"confirm-password": "Confirmar senha", "confirm-password": "Confirmar senha",
"contains": "Contém", "contains": "Contém",
"regex-match": "Corresponde à expressão regular",
"regex-not-match": "Não corresponde à expressão regular",
"content": "Conteúdo", "content": "Conteúdo",
"continue": "Continuar", "continue": "Continuar",
"conversion": "Conversão", "conversion": "Conversão",
@ -169,6 +171,9 @@
"manager": "Gestor", "manager": "Gestor",
"max": "Máximo", "max": "Máximo",
"maximize": "Expandir", "maximize": "Expandir",
"match": "Correspondência",
"match-all": "Todos",
"match-any": "Qualquer",
"medium": "Médio", "medium": "Médio",
"member": "Membro", "member": "Membro",
"members": "Membros", "members": "Membros",

View file

@ -51,6 +51,8 @@
"confirm": "Confirmă", "confirm": "Confirmă",
"confirm-password": "Confirmare parolă", "confirm-password": "Confirmare parolă",
"contains": "Conține", "contains": "Conține",
"regex-match": "Se potrivește cu expresia regulată",
"regex-not-match": "Nu se potrivește cu expresia regulată",
"content": "Conținut", "content": "Conținut",
"continue": "Continuă", "continue": "Continuă",
"conversion": "Conversie", "conversion": "Conversie",
@ -169,6 +171,9 @@
"manager": "Manager", "manager": "Manager",
"max": "Max", "max": "Max",
"maximize": "Extinde", "maximize": "Extinde",
"match": "Potrivire",
"match-all": "Toate",
"match-any": "Oricare",
"medium": "Mediu", "medium": "Mediu",
"member": "Membru", "member": "Membru",
"members": "Membri", "members": "Membri",

View file

@ -51,6 +51,8 @@
"confirm": "Подтвердить", "confirm": "Подтвердить",
"confirm-password": "Подтвердить пароль", "confirm-password": "Подтвердить пароль",
"contains": "Содержит", "contains": "Содержит",
"regex-match": "Соответствует регулярному выражению",
"regex-not-match": "Не соответствует регулярному выражению",
"content": "Контент", "content": "Контент",
"continue": "Продолжить", "continue": "Продолжить",
"conversion": "Конверсия", "conversion": "Конверсия",
@ -169,6 +171,9 @@
"manager": "Менеджер", "manager": "Менеджер",
"max": "Максимум", "max": "Максимум",
"maximize": "Развернуть", "maximize": "Развернуть",
"match": "Соответствие",
"match-all": "Все",
"match-any": "Любой",
"medium": "Средний", "medium": "Средний",
"member": "Участник", "member": "Участник",
"members": "Участники", "members": "Участники",

View file

@ -51,6 +51,8 @@
"confirm": "තහවුරු කරන්න", "confirm": "තහවුරු කරන්න",
"confirm-password": "මුරපදය සත්‍යාපනය කරන්න", "confirm-password": "මුරපදය සත්‍යාපනය කරන්න",
"contains": "අඩංගු වේ", "contains": "අඩංගු වේ",
"regex-match": "Regex සමඟ ගැලපේ",
"regex-not-match": "Regex සමඟ නොගැලපේ",
"content": "අන්තර්ගතය", "content": "අන්තර්ගතය",
"continue": "ඉදිරියට", "continue": "ඉදිරියට",
"conversion": "පරිවර්තනය", "conversion": "පරිවර්තනය",
@ -169,6 +171,9 @@
"manager": "කළමනාකරු", "manager": "කළමනාකරු",
"max": "උපරිම", "max": "උපරිම",
"maximize": "Expand", "maximize": "Expand",
"match": "ගැළපීම",
"match-all": "සියල්ල",
"match-any": "ඕනෑම",
"medium": "මාධ්‍යය", "medium": "මාධ්‍යය",
"member": "සාමාජිකයා", "member": "සාමාජිකයා",
"members": "සාමාජිකයින්", "members": "සාමාජිකයින්",

View file

@ -51,6 +51,8 @@
"confirm": "Potvrdiť", "confirm": "Potvrdiť",
"confirm-password": "Potvrdiť heslo", "confirm-password": "Potvrdiť heslo",
"contains": "Obsahuje", "contains": "Obsahuje",
"regex-match": "Zodpovedá regulárnemu výrazu",
"regex-not-match": "Nezodpovedá regulárnemu výrazu",
"content": "Obsah", "content": "Obsah",
"continue": "Pokračovať", "continue": "Pokračovať",
"conversion": "Konverzia", "conversion": "Konverzia",
@ -169,6 +171,9 @@
"manager": "Manažér", "manager": "Manažér",
"max": "Maximum", "max": "Maximum",
"maximize": "Rozbaliť", "maximize": "Rozbaliť",
"match": "Zhoda",
"match-all": "Všetky",
"match-any": "Akýkoľvek",
"medium": "Stredný", "medium": "Stredný",
"member": "Člen", "member": "Člen",
"members": "Členovia", "members": "Členovia",

View file

@ -51,6 +51,8 @@
"confirm": "Potrdi", "confirm": "Potrdi",
"confirm-password": "Potrdi geslo", "confirm-password": "Potrdi geslo",
"contains": "Vsebuje", "contains": "Vsebuje",
"regex-match": "Ustreza regularnemu izrazu",
"regex-not-match": "Ne ustreza regularnemu izrazu",
"content": "Vsebina", "content": "Vsebina",
"continue": "Nadaljuj", "continue": "Nadaljuj",
"conversion": "Konverzija", "conversion": "Konverzija",
@ -169,6 +171,9 @@
"manager": "Upravitelj", "manager": "Upravitelj",
"max": "Največ", "max": "Največ",
"maximize": "Povečaj", "maximize": "Povečaj",
"match": "Ujemanje",
"match-all": "Vse",
"match-any": "Katero koli",
"medium": "Medij", "medium": "Medij",
"member": "Član", "member": "Član",
"members": "Člani", "members": "Člani",

View file

@ -51,6 +51,8 @@
"confirm": "Bekräfta", "confirm": "Bekräfta",
"confirm-password": "Bekräfta lösenord", "confirm-password": "Bekräfta lösenord",
"contains": "Innehåller", "contains": "Innehåller",
"regex-match": "Matchar reguljärt uttryck",
"regex-not-match": "Matchar inte reguljärt uttryck",
"content": "Innehåll", "content": "Innehåll",
"continue": "Fortsätt", "continue": "Fortsätt",
"conversion": "Konvertering", "conversion": "Konvertering",
@ -169,6 +171,9 @@
"manager": "Ansvarig", "manager": "Ansvarig",
"max": "Max", "max": "Max",
"maximize": "Expandera", "maximize": "Expandera",
"match": "Matchning",
"match-all": "Alla",
"match-any": "Vilken som helst",
"medium": "Medium", "medium": "Medium",
"member": "Medlem", "member": "Medlem",
"members": "Medlemmar", "members": "Medlemmar",

View file

@ -51,6 +51,8 @@
"confirm": "உறுதிப்படுத்து", "confirm": "உறுதிப்படுத்து",
"confirm-password": "கடவுச்சொல்லை உறுதிப்படுத்தவும்", "confirm-password": "கடவுச்சொல்லை உறுதிப்படுத்தவும்",
"contains": "உள்ளடக்கியது", "contains": "உள்ளடக்கியது",
"regex-match": "Regex உடன் பொருந்துகிறது",
"regex-not-match": "Regex உடன் பொருந்தாது",
"content": "உள்ளடக்கம்", "content": "உள்ளடக்கம்",
"continue": "தொடர்", "continue": "தொடர்",
"conversion": "மாற்றம்", "conversion": "மாற்றம்",
@ -169,6 +171,9 @@
"manager": "மேலாளர்", "manager": "மேலாளர்",
"max": "அதிகபட்சம்", "max": "அதிகபட்சம்",
"maximize": "Expand", "maximize": "Expand",
"match": "பொருத்தம்",
"match-all": "அனைத்தும்",
"match-any": "எதுவும்",
"medium": "ஊடகம்", "medium": "ஊடகம்",
"member": "உறுப்பினர்", "member": "உறுப்பினர்",
"members": "உறுப்பினர்கள்", "members": "உறுப்பினர்கள்",

View file

@ -51,6 +51,8 @@
"confirm": "ยืนยัน", "confirm": "ยืนยัน",
"confirm-password": "ยืนยันรหัสผ่าน", "confirm-password": "ยืนยันรหัสผ่าน",
"contains": "มี", "contains": "มี",
"regex-match": "ตรงกับนิพจน์ทั่วไป",
"regex-not-match": "ไม่ตรงกับนิพจน์ทั่วไป",
"content": "เนื้อหา", "content": "เนื้อหา",
"continue": "ดำเนินต่อ", "continue": "ดำเนินต่อ",
"conversion": "การแปลง", "conversion": "การแปลง",
@ -169,6 +171,9 @@
"manager": "ผู้จัดการ", "manager": "ผู้จัดการ",
"max": "สูงสุด", "max": "สูงสุด",
"maximize": "Expand", "maximize": "Expand",
"match": "จับคู่",
"match-all": "ทั้งหมด",
"match-any": "ใดๆ",
"medium": "สื่อ", "medium": "สื่อ",
"member": "สมาชิก", "member": "สมาชิก",
"members": "สมาชิก", "members": "สมาชิก",

View file

@ -51,6 +51,8 @@
"confirm": "Onayla", "confirm": "Onayla",
"confirm-password": "Parolayı onayla", "confirm-password": "Parolayı onayla",
"contains": "İçeriği", "contains": "İçeriği",
"regex-match": "Düzenli ifadeyle eşleşir",
"regex-not-match": "Düzenli ifadeyle eşleşmez",
"content": "İçerik", "content": "İçerik",
"continue": "Devam et", "continue": "Devam et",
"conversion": "Dönüşüm", "conversion": "Dönüşüm",
@ -169,6 +171,9 @@
"manager": "Yönetici", "manager": "Yönetici",
"max": "Maks", "max": "Maks",
"maximize": "Genişlet", "maximize": "Genişlet",
"match": "Eşleşme",
"match-all": "Tümü",
"match-any": "Herhangi",
"medium": "Orta", "medium": "Orta",
"member": "Üye", "member": "Üye",
"members": "Üyeler", "members": "Üyeler",

View file

@ -51,6 +51,8 @@
"confirm": "Підтвердити", "confirm": "Підтвердити",
"confirm-password": "Підтвердити пароль", "confirm-password": "Підтвердити пароль",
"contains": "Містить", "contains": "Містить",
"regex-match": "Відповідає регулярному виразу",
"regex-not-match": "Не відповідає регулярному виразу",
"content": "Вміст", "content": "Вміст",
"continue": "Продовжити", "continue": "Продовжити",
"conversion": "Конверсія", "conversion": "Конверсія",
@ -169,6 +171,9 @@
"manager": "Менеджер", "manager": "Менеджер",
"max": "Макс.", "max": "Макс.",
"maximize": "Розгорнути", "maximize": "Розгорнути",
"match": "Відповідність",
"match-all": "Усі",
"match-any": "Будь-який",
"medium": "Середній", "medium": "Середній",
"member": "Учасник", "member": "Учасник",
"members": "Учасники", "members": "Учасники",

View file

@ -51,6 +51,8 @@
"confirm": "تصدیق کریں", "confirm": "تصدیق کریں",
"confirm-password": "پاس ورڈ کی تصدیق کریں", "confirm-password": "پاس ورڈ کی تصدیق کریں",
"contains": "شامل ہے", "contains": "شامل ہے",
"regex-match": "ریجیکس سے مطابقت رکھتا ہے",
"regex-not-match": "ریجیکس سے مطابقت نہیں رکھتا",
"content": "مواد", "content": "مواد",
"continue": "جاری رکھیں", "continue": "جاری رکھیں",
"conversion": "تبدیلی", "conversion": "تبدیلی",
@ -169,6 +171,9 @@
"manager": "منتظم", "manager": "منتظم",
"max": "زیادہ سے زیادہ", "max": "زیادہ سے زیادہ",
"maximize": "Expand", "maximize": "Expand",
"match": "مطابقت",
"match-all": "سب",
"match-any": "کوئی بھی",
"medium": "میڈیم", "medium": "میڈیم",
"member": "رکن", "member": "رکن",
"members": "اراکین", "members": "اراکین",

View file

@ -51,6 +51,8 @@
"confirm": "Tasdiqlash", "confirm": "Tasdiqlash",
"confirm-password": "Parolni tasdiqlash", "confirm-password": "Parolni tasdiqlash",
"contains": "Oʻz ichiga oladi", "contains": "Oʻz ichiga oladi",
"regex-match": "Muntazam ifodaga mos keladi",
"regex-not-match": "Muntazam ifodaga mos kelmaydi",
"content": "Kontent", "content": "Kontent",
"continue": "Davom etish", "continue": "Davom etish",
"conversion": "Konversiya", "conversion": "Konversiya",
@ -169,6 +171,9 @@
"manager": "Menejer", "manager": "Menejer",
"max": "Maksimal", "max": "Maksimal",
"maximize": "Kattalashtirish", "maximize": "Kattalashtirish",
"match": "Moslik",
"match-all": "Barchasi",
"match-any": "Istalgan",
"medium": "Vosita", "medium": "Vosita",
"member": "A'zo", "member": "A'zo",
"members": "A'zolar", "members": "A'zolar",

View file

@ -51,6 +51,8 @@
"confirm": "Xác nhận", "confirm": "Xác nhận",
"confirm-password": "Xác nhận mật khẩu", "confirm-password": "Xác nhận mật khẩu",
"contains": "Chứa", "contains": "Chứa",
"regex-match": "Khớp với biểu thức chính quy",
"regex-not-match": "Không khớp với biểu thức chính quy",
"content": "Nội dung", "content": "Nội dung",
"continue": "Tiếp tục", "continue": "Tiếp tục",
"conversion": "Chuyển đổi", "conversion": "Chuyển đổi",
@ -169,6 +171,9 @@
"manager": "Quản lý", "manager": "Quản lý",
"max": "Tối đa", "max": "Tối đa",
"maximize": "Phóng to", "maximize": "Phóng to",
"match": "Khớp",
"match-all": "Tất cả",
"match-any": "Bất kỳ",
"medium": "Phương tiện", "medium": "Phương tiện",
"member": "Thành viên", "member": "Thành viên",
"members": "Các thành viên", "members": "Các thành viên",

View file

@ -51,6 +51,8 @@
"confirm": "确认", "confirm": "确认",
"confirm-password": "确认密码", "confirm-password": "确认密码",
"contains": "包含", "contains": "包含",
"regex-match": "匹配正则表达式",
"regex-not-match": "不匹配正则表达式",
"content": "内容", "content": "内容",
"continue": "继续", "continue": "继续",
"conversion": "转化", "conversion": "转化",
@ -169,6 +171,9 @@
"manager": "管理者", "manager": "管理者",
"max": "最大", "max": "最大",
"maximize": "展开", "maximize": "展开",
"match": "匹配",
"match-all": "全部",
"match-any": "任意",
"medium": "中等", "medium": "中等",
"member": "成员", "member": "成员",
"members": "成员", "members": "成员",

View file

@ -51,6 +51,8 @@
"confirm": "確認", "confirm": "確認",
"confirm-password": "確認密碼", "confirm-password": "確認密碼",
"contains": "包含", "contains": "包含",
"regex-match": "符合正則表達式",
"regex-not-match": "不符合正則表達式",
"content": "內容", "content": "內容",
"continue": "繼續", "continue": "繼續",
"conversion": "轉換", "conversion": "轉換",
@ -169,6 +171,9 @@
"manager": "管理者", "manager": "管理者",
"max": "最大值", "max": "最大值",
"maximize": "Expand", "maximize": "Expand",
"match": "符合",
"match-all": "全部",
"match-any": "任意",
"medium": "媒介", "medium": "媒介",
"member": "成員", "member": "成員",
"members": "成員", "members": "成員",

View file

@ -47,7 +47,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
payload: { payload: {
pixel: pixel.id, pixel: pixel.id,
url: request.url, url: request.url,
referrer: request.headers.get('referer'), referrer: request.headers.get("referer") || undefined,
}, },
}; };

View file

@ -45,7 +45,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
payload: { payload: {
link: link.id, link: link.id,
url: request.url, url: request.url,
referrer: request.headers.get('referer'), referrer: request.headers.get("referer") || undefined,
}, },
}; };

View file

@ -49,7 +49,13 @@ export function MobileNav() {
<MobileMenuButton> <MobileMenuButton>
{({ close }) => { {({ close }) => {
return ( return (
<Column gap="2" display="flex" flex-direction="column" height="100vh" padding="1"> <Column
gap="2"
display="flex"
flex-direction="column"
padding="1"
style={{ height: '100dvh' }}
>
{isMain && {isMain &&
links.map(link => { links.map(link => {
return ( return (
@ -63,8 +69,8 @@ export function MobileNav() {
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />} {websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
{isAdmin && <AdminNav onItemClick={close} />} {isAdmin && <AdminNav onItemClick={close} />}
{isSettings && <SettingsNav onItemClick={close} />} {isSettings && <SettingsNav onItemClick={close} />}
<Row onClick={close} style={{ marginTop: 'auto' }}> <Row style={{ marginTop: 'auto' }}>
<UserButton /> <UserButton onClose={close} />
</Row> </Row>
</Column> </Column>
); );

View file

@ -1,10 +1,13 @@
import { Column, Focusable, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import Link from 'next/link';
import { IconLabel } from '@/components/common/IconLabel';
import { NavMenu } from '@/components/common/NavMenu'; import { NavMenu } from '@/components/common/NavMenu';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, User, Users } from '@/components/icons'; import { ArrowLeft, Globe, User, Users } from '@/components/icons';
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) { export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const { pathname } = useNavigation(); const { pathname, renderUrl } = useNavigation();
const items = [ const items = [
{ {
@ -37,12 +40,29 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
?.find(({ path }) => path && pathname.startsWith(path))?.id; ?.find(({ path }) => path && pathname.startsWith(path))?.id;
return ( return (
<NavMenu <Column gap="2">
items={items} <Link href={renderUrl('/boards', false)} role="button" onClick={onItemClick}>
title={t(labels.admin)} <TooltipTrigger delay={0}>
selectedKey={selectedKey} <Focusable>
allowMinimize={false} <Row
onItemClick={onItemClick} alignItems="center"
/> hover={{ backgroundColor: 'surface-sunken' }}
borderRadius
minHeight="40px"
>
<IconLabel icon={<ArrowLeft />} label={t(labels.back)} padding />
</Row>
</Focusable>
<Tooltip placement="right">{t(labels.back)}</Tooltip>
</TooltipTrigger>
</Link>
<NavMenu
items={items}
title={t(labels.admin)}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
); );
} }

View file

@ -10,6 +10,7 @@ import { TeamDeleteForm } from '../../teams/[teamId]/TeamDeleteForm';
export function AdminTeamsTable({ export function AdminTeamsTable({
data = [], data = [],
showActions = true, showActions = true,
...props
}: { }: {
data: any[]; data: any[];
showActions?: boolean; showActions?: boolean;
@ -19,7 +20,7 @@ export function AdminTeamsTable({
return ( return (
<> <>
<DataTable data={data}> <DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)} width="1fr"> <DataColumn id="name" label={t(labels.name)} width="1fr">
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>} {(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
</DataColumn> </DataColumn>

View file

@ -11,6 +11,7 @@ import { UserDeleteForm } from './UserDeleteForm';
export function UsersTable({ export function UsersTable({
data = [], data = [],
showActions = true, showActions = true,
...props
}: { }: {
data: any[]; data: any[];
showActions?: boolean; showActions?: boolean;
@ -20,7 +21,7 @@ export function UsersTable({
return ( return (
<> <>
<DataTable data={data}> <DataTable data={data} {...props}>
<DataColumn id="username" label={t(labels.username)} width="2fr"> <DataColumn id="username" label={t(labels.username)} width="2fr">
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>} {(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn> </DataColumn>

View file

@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) { export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
const { t, labels, messages, getMessage } = useMessages(); const { t, labels, messages, getErrorMessage } = useMessages();
const user = useUser(); const user = useUser();
const { user: login } = useLoginQuery(); const { user: login } = useLoginQuery();
@ -30,7 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
}; };
return ( return (
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}> <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={user}>
<FormField name="username" label={t(labels.username)}> <FormField name="username" label={t(labels.username)}>
<TextField data-test="input-username" /> <TextField data-test="input-username" />
</FormField> </FormField>

View file

@ -7,13 +7,13 @@ import { useMessages } from '@/components/hooks';
import { Edit, Trash, Users } from '@/components/icons'; import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton'; import { MenuButton } from '@/components/input/MenuButton';
export function AdminWebsitesTable({ data = [] }: { data: any[] }) { export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const [deleteWebsite, setDeleteWebsite] = useState(null); const [deleteWebsite, setDeleteWebsite] = useState(null);
return ( return (
<> <>
<DataTable data={data}> <DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)}> <DataColumn id="name" label={t(labels.name)}>
{(row: any) => ( {(row: any) => (
<Text truncate> <Text truncate>

View file

@ -1,19 +1,16 @@
import { Column, Grid, Row, Text } from '@umami/react-zen'; import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import classNames from 'classnames'; import { useMemo, useState } from 'react';
import { colord } from 'colord'; import { GridRow } from '@/components/common/GridRow';
import { useCallback, useMemo, useState } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { TypeIcon } from '@/components/common/TypeIcon'; import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { CurrencySelect } from '@/components/input/CurrencySelect'; import { CurrencySelect } from '@/components/input/CurrencySelect';
import { ListTable } from '@/components/metrics/ListTable'; import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricLabel } from '@/components/metrics/MetricLabel';
import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts'; import { RevenueChart } from '@/components/metrics/RevenueChart';
import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
import { formatLongCurrency, formatLongNumber } from '@/lib/format'; import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage'; import { getItem, setItem } from '@/lib/storage';
@ -35,83 +32,49 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
}; };
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const { locale, dateLocale } = useLocale(); const { compare, isAllTime } = useDateRange();
const { countryNames } = useCountryNames(locale);
const { data, error, isLoading } = useResultQuery<any>('revenue', { const { data, error, isLoading } = useResultQuery<any>('revenue', {
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
currency, currency,
compare,
}); });
const renderCountryName = useCallback(
({ label: code }) => (
<Row className={classNames(locale)} gap>
<TypeIcon type="country" value={code} />
<Text>{countryNames[code] || t(labels.unknown)}</Text>
</Row>
),
[countryNames, locale],
);
const chartData: any = useMemo(() => {
if (!data) return [];
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
obj[x].push({ x: t, y });
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, startDate, endDate, unit]);
const metrics = useMemo(() => { const metrics = useMemo(() => {
if (!data) return []; if (!data) return [];
const { sum, count, unique_count } = data.total; const { sum, count, average, unique_count, comparison } = data.total;
return [ return [
{ {
value: sum, value: sum,
label: t(labels.total), label: t(labels.total),
formatValue: n => formatLongCurrency(n, currency), change: comparison ? sum - comparison.sum : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
}, },
{ {
value: count ? sum / count : 0, value: average,
label: t(labels.average), label: t(labels.average),
formatValue: n => formatLongCurrency(n, currency), change: comparison ? average - comparison.average : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
}, },
{ {
value: count, value: count,
label: t(labels.transactions), label: t(labels.transactions),
change: comparison ? count - comparison.count : 0,
formatValue: formatLongNumber, formatValue: formatLongNumber,
}, },
{ {
value: unique_count, value: unique_count,
label: t(labels.uniqueCustomers), label: t(labels.uniqueCustomers),
change: comparison ? unique_count - comparison.unique_count : 0,
formatValue: formatLongNumber, formatValue: formatLongNumber,
}, },
] as any; ] as any;
}, [data, locale]); }, [data]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); const renderLabel = (type: string) => (data: any) => <MetricLabel type={type} data={data} />;
return ( return (
<Column gap> <Column gap>
@ -122,37 +85,119 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{data && ( {data && (
<Column gap> <Column gap>
<MetricsBar> <MetricsBar>
{metrics?.map(({ label, value, formatValue }) => { {metrics?.map(({ label, value, change, formatValue }) => {
return ( return (
<MetricCard key={label} value={value} label={label} formatValue={formatValue} /> <MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
); );
})} })}
</MetricsBar> </MetricsBar>
<Panel> <Panel>
<BarChart <RevenueChart
chartData={chartData} data={data.chart}
unit={unit}
minDate={startDate} minDate={startDate}
maxDate={endDate} maxDate={endDate}
unit={unit}
stacked={true}
currency={currency} currency={currency}
renderXLabel={renderXLabel}
height="400px"
/>
</Panel>
<Panel>
<ListTable
title={t(labels.country)}
metric={t(labels.revenue)}
data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}))}
currency={currency}
renderLabel={renderCountryName}
/> />
</Panel> </Panel>
<Grid gap="3">
<GridRow layout="two">
<Panel>
<Heading size="2xl">{t(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{t(labels.referrers)}</Tab>
<Tab id="channel">{t(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<ListTable
title={t(labels.referrer)}
metric={t(labels.revenue)}
data={data?.referrer.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('referrer')}
/>
</TabPanel>
<TabPanel id="channel">
<ListTable
title={t(labels.channel)}
metric={t(labels.revenue)}
data={data?.channel.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('channel')}
/>
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="2xl">{t(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{t(labels.countries)}</Tab>
<Tab id="region">{t(labels.regions)}</Tab>
</TabList>
<TabPanel id="country">
<ListTable
title={t(labels.country)}
metric={t(labels.revenue)}
data={data?.country.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('country')}
/>
</TabPanel>
<TabPanel id="region">
<ListTable
title={t(labels.region)}
metric={t(labels.revenue)}
data={data?.region.map(
({
name,
value,
country,
}: {
name: string;
value: number;
country: string;
}) => ({
label: name,
country,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('region')}
/>
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Grid>
</Column> </Column>
)} )}
</LoadingPanel> </LoadingPanel>

View file

@ -10,6 +10,7 @@ import {
Loading, Loading,
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
import { ActionSelect } from '@/components/input/ActionSelect'; import { ActionSelect } from '@/components/input/ActionSelect';
import { DateFilter } from '@/components/input/DateFilter'; import { DateFilter } from '@/components/input/DateFilter';
@ -32,6 +33,11 @@ export function CohortEditForm({
}) { }) {
const { data } = useWebsiteCohortQuery(websiteId, cohortId); const { data } = useWebsiteCohortQuery(websiteId, cohortId);
const { t, labels, messages, getErrorMessage } = useMessages(); const { t, labels, messages, getErrorMessage } = useMessages();
const [currentMatch, setCurrentMatch] = useState<string>('all');
useEffect(() => {
setCurrentMatch((data?.parameters as any)?.match || 'all');
}, [data]);
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`, `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
@ -41,14 +47,23 @@ export function CohortEditForm({
); );
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: any) => {
await mutateAsync(formData, { await mutateAsync(
onSuccess: async () => { {
toast(t(messages.saved)); ...formData,
touch('cohorts'); parameters: {
onSave?.(); ...formData.parameters,
onClose?.(); match: currentMatch !== 'all' ? currentMatch : undefined,
},
}, },
}); {
onSuccess: async () => {
toast(t(messages.saved));
touch('cohorts');
onSave?.();
onClose?.();
},
},
);
}; };
if (cohortId && !data) { if (cohortId && !data) {
@ -105,7 +120,12 @@ export function CohortEditForm({
<Column> <Column>
<Label>{t(labels.filters)}</Label> <Label>{t(labels.filters)}</Label>
<FormField name="parameters.filters"> <FormField name="parameters.filters">
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} /> <FieldFilters
websiteId={websiteId}
exclude={['path', 'event']}
match={currentMatch}
onMatchChange={setCurrentMatch}
/>
</FormField> </FormField>
</Column> </Column>

View file

@ -1,12 +1,11 @@
'use client'; 'use client';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import locale from 'date-fns/locale/af'; import { type Key, useState } from 'react';
import { type Key, useMemo, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useDateRange, useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { EventsChart } from '@/components/metrics/EventsChart'; import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricCard } from '@/components/metrics/MetricCard';
@ -21,6 +20,7 @@ const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) { export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
const { isAllTime } = useDateRange();
const { t, labels, getErrorMessage } = useMessages(); const { t, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({ const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId, websiteId,
@ -31,34 +31,36 @@ export function EventsPage({ websiteId }) {
setTab(value); setTab(value);
}; };
const metrics = useMemo(() => { const { events, visitors, visits, uniqueEvents, comparison } = data || {};
if (!data) return [];
const { events, visitors, visits, uniqueEvents } = data || {}; const metrics = data
? [
return [ {
{ value: visitors,
value: visitors, label: t(labels.visitors),
label: t(labels.visitors), change: visitors - comparison.visitors,
formatValue: formatLongNumber, formatValue: formatLongNumber,
}, },
{ {
value: visits, value: visits,
label: t(labels.visits), label: t(labels.visits),
formatValue: formatLongNumber, change: visits - comparison.visits,
}, formatValue: formatLongNumber,
{ },
value: events, {
label: t(labels.events), value: events,
formatValue: formatLongNumber, label: t(labels.events),
}, change: events - comparison.events,
{ formatValue: formatLongNumber,
value: uniqueEvents, },
label: t(labels.uniqueEvents), {
formatValue: formatLongNumber, value: uniqueEvents,
}, label: t(labels.uniqueEvents),
] as any; change: uniqueEvents - comparison.uniqueEvents,
}, [data, locale]); formatValue: formatLongNumber,
},
]
: null;
return ( return (
<Column gap="3"> <Column gap="3">
@ -71,8 +73,17 @@ export function EventsPage({ websiteId }) {
minHeight="136px" minHeight="136px"
> >
<MetricsBar> <MetricsBar>
{metrics?.map(({ label, value, formatValue }) => { {metrics?.map(({ label, value, change, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />; return (
<MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
);
})} })}
</MetricsBar> </MetricsBar>
</LoadingPanel> </LoadingPanel>
@ -89,13 +100,14 @@ export function EventsPage({ websiteId }) {
<TabPanel id="chart"> <TabPanel id="chart">
<Column gap="6"> <Column gap="6">
<Column border="bottom" paddingBottom="6"> <Column border="bottom" paddingBottom="6">
<EventsChart websiteId={websiteId} /> <EventsChart websiteId={websiteId} limit={50} />
</Column> </Column>
<MetricsTable <MetricsTable
websiteId={websiteId} websiteId={websiteId}
type="event" type="event"
title={t(labels.event)} title={t(labels.event)}
metric={t(labels.count)} metric={t(labels.count)}
limit={50}
/> />
</Column> </Column>
</TabPanel> </TabPanel>

View file

@ -8,6 +8,7 @@ import {
Loading, Loading,
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
@ -28,6 +29,11 @@ export function SegmentEditForm({
}) { }) {
const { data } = useWebsiteSegmentQuery(websiteId, segmentId); const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
const { t, labels, messages, getErrorMessage } = useMessages(); const { t, labels, messages, getErrorMessage } = useMessages();
const [currentMatch, setCurrentMatch] = useState<string>('all');
useEffect(() => {
setCurrentMatch((data?.parameters as any)?.match || 'all');
}, [data]);
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`, `/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
@ -37,14 +43,23 @@ export function SegmentEditForm({
); );
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: any) => {
await mutateAsync(formData, { await mutateAsync(
onSuccess: async () => { {
toast(t(messages.saved)); ...formData,
touch('segments'); parameters: {
onSave?.(); ...formData.parameters,
onClose?.(); match: currentMatch !== 'all' ? currentMatch : undefined,
},
}, },
}); {
onSuccess: async () => {
toast(t(messages.saved));
touch('segments');
onSave?.();
onClose?.();
},
},
);
}; };
if (segmentId && !data) { if (segmentId && !data) {
@ -64,7 +79,11 @@ export function SegmentEditForm({
<> <>
<Label>{t(labels.filters)}</Label> <Label>{t(labels.filters)}</Label>
<FormField name="parameters.filters" rules={{ required: t(labels.required) }}> <FormField name="parameters.filters" rules={{ required: t(labels.required) }}>
<FieldFilters websiteId={websiteId} /> <FieldFilters
websiteId={websiteId}
match={currentMatch}
onMatchChange={setCurrentMatch}
/>
</FormField> </FormField>
</> </>
)} )}

View file

@ -18,7 +18,7 @@ export function SharesTable(props: DataTableProps) {
}; };
return ( return (
<DataTable {...props}> <DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="name" label={t(labels.name)}> <DataColumn id="name" label={t(labels.name)}>
{({ name }: any) => name} {({ name }: any) => name}
</DataColumn> </DataColumn>
@ -27,16 +27,14 @@ export function SharesTable(props: DataTableProps) {
const url = getUrl(slug); const url = getUrl(slug);
return ( return (
<ExternalLink href={url} prefetch={false}> <ExternalLink href={url} prefetch={false}>
{url} {isMobile ? slug : url}
</ExternalLink> </ExternalLink>
); );
}} }}
</DataColumn> </DataColumn>
{!isMobile && ( <DataColumn id="created" label={t(labels.created)}>
<DataColumn id="created" label={t(labels.created)}> {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} </DataColumn>
</DataColumn>
)}
<DataColumn id="action" align="end" width="100px"> <DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => { {({ id, slug }: any) => {
return ( return (

View file

@ -1,8 +1,11 @@
import { getCompareDate } from '@/lib/date';
import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema'; import { reportResultSchema } from '@/lib/schema';
import { canViewWebsite } from '@/permissions'; import { canViewWebsite } from '@/permissions';
import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue'; import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue';
import { getRevenueMetrics } from '@/queries/sql/reports/getRevenueMetrics';
import { getRevenueStats } from '@/queries/sql/reports/getRevenueStats';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema); const { auth, body, error } = await parseRequest(request, reportResultSchema);
@ -20,7 +23,19 @@ export async function POST(request: Request) {
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); const [{ chart }, total, metrics] = await Promise.all([
getRevenue(websiteId, parameters as RevenuParameters, filters),
getRevenueStats(websiteId, parameters as RevenuParameters, filters),
getRevenueMetrics(websiteId, parameters as RevenuParameters, filters),
]);
return json(data); const { compare = 'prev' } = parameters as RevenuParameters;
const { startDate, endDate } = getCompareDate(compare, parameters.startDate, parameters.endDate);
const comparison = await getRevenueStats(
websiteId,
{ ...(parameters as RevenuParameters), startDate, endDate },
filters,
);
return json({ chart, total: { ...total, comparison }, ...metrics });
} }

View file

@ -14,6 +14,7 @@ export async function GET(
endAt: z.coerce.number().int(), endAt: z.coerce.number().int(),
unit: unitParam.optional(), unit: unitParam.optional(),
timezone: timezoneParam, timezone: timezoneParam,
limit: z.coerce.number().optional(),
...filterParams, ...filterParams,
}); });
@ -29,9 +30,10 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const { limit } = query;
const filters = await getQueryFilters(query, websiteId); const filters = await getQueryFilters(query, websiteId);
const data = await getEventStats(websiteId, filters); const data = await getEventStats(websiteId, { limit }, filters);
return json(data); return json(data);
} }

View file

@ -1,3 +1,4 @@
import { getCompareDate } from '@/lib/date';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { filterParams, withDateRange } from '@/lib/schema'; import { filterParams, withDateRange } from '@/lib/schema';
@ -28,5 +29,17 @@ export async function GET(
const data = await getWebsiteEventStats(websiteId, filters); const data = await getWebsiteEventStats(websiteId, filters);
return json({ data }); const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteEventStats(websiteId, {
...filters,
startDate,
endDate,
});
return json({ data: { ...data, comparison } });
} }

View file

@ -36,7 +36,7 @@ export function LoginForm() {
<Logo /> <Logo />
</Icon> </Icon>
<Heading>umami</Heading> <Heading>umami</Heading>
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}> <Form onSubmit={handleSubmit} error={getErrorMessage(error)} style={{ minWidth: 300 }}>
<FormField <FormField
label={t(labels.username)} label={t(labels.username)}
data-test="input-username" data-test="input-username"

View file

@ -56,7 +56,7 @@ export function DataGrid({
(page: number) => { (page: number) => {
router.push(updateParams({ search, page })); router.push(updateParams({ search, page }));
}, },
[search], [search, updateParams],
); );
const child = data ? (typeof children === 'function' ? children(data) : children) : null; const child = data ? (typeof children === 'function' ? children(data) : children) : null;

View file

@ -1,6 +1,17 @@
import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen'; import {
Button,
Column,
Grid,
Icon,
Label,
ListItem,
Loading,
Select,
TextField,
} from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { MultiSelect } from '@/components/common/MultiSelect';
import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks'; import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks';
import { X } from '@/components/icons'; import { X } from '@/components/icons';
import { isSearchOperator } from '@/lib/params'; import { isSearchOperator } from '@/lib/params';
@ -12,7 +23,7 @@ export interface FilterRecordProps {
endDate: Date; endDate: Date;
name: string; name: string;
operator: string; operator: string;
value: string; value: string | string[];
onSelect?: (name: string, value: any) => void; onSelect?: (name: string, value: any) => void;
onRemove?: (name: string) => void; onRemove?: (name: string) => void;
onChange?: (name: string, value: string) => void; onChange?: (name: string, value: string) => void;
@ -31,7 +42,8 @@ export function FilterRecord({
onChange, onChange,
}: FilterRecordProps) { }: FilterRecordProps) {
const { fields, operators } = useFilters(); const { fields, operators } = useFilters();
const [selected, setSelected] = useState(value); const initValues = Array.isArray(value) ? value : value ? value.split(',') : [];
const [selected, setSelected] = useState<string[]>(initValues);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const { data, isLoading } = useWebsiteValuesQuery({ const { data, isLoading } = useWebsiteValuesQuery({
@ -53,10 +65,15 @@ export function FilterRecord({
}; };
const handleSelectValue = (value: string) => { const handleSelectValue = (value: string) => {
setSelected(value); setSelected([value]);
onChange?.(name, value); onChange?.(name, value);
}; };
const handleMultiSelectValue = (values: string[]) => {
setSelected(values);
onChange?.(name, values.join(','));
};
return ( return (
<Column> <Column>
<Label>{fields.find(f => f.name === name)?.label}</Label> <Label>{fields.find(f => f.name === name)?.label}</Label>
@ -72,26 +89,30 @@ export function FilterRecord({
))} ))}
</Select> </Select>
{isSearch && ( {isSearch && (
<TextField value={selected} defaultValue={selected} onChange={handleSelectValue} /> <TextField
value={selected[0] || ''}
defaultValue={selected[0] || ''}
onChange={handleSelectValue}
/>
)} )}
{!isSearch && ( {!isSearch && (
<Select <MultiSelect
value={selected} value={selected}
onChange={handleSelectValue} onChange={handleMultiSelectValue}
searchValue={search} searchValue={search}
onSearch={handleSearch} onSearch={handleSearch}
isLoading={isLoading} renderValue={values =>
listProps={{ renderEmptyState: () => <Empty /> }} values.length > 0 ? values.map(v => formatValue(v, type)).join(', ') : undefined
}
renderEmptyState={() => (isLoading ? <Loading icon="dots" /> : <Empty />)}
allowSearch allowSearch
> >
{items?.map(({ value }) => { {items.map(({ value }) => (
return ( <ListItem key={value} id={value}>
<ListItem key={value} id={value}> {formatValue(value, type)}
{formatValue(value, type)} </ListItem>
</ListItem> ))}
); </MultiSelect>
})}
</Select>
)} )}
</Grid> </Grid>
<Column justifyContent="flex-start"> <Column justifyContent="flex-start">

View file

@ -0,0 +1,71 @@
import { Button, Column, Icon, List, MenuTrigger, Popover, SearchField } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { ChevronRight } from '@/components/icons';
interface MultiSelectProps {
value?: string[];
onChange?: (values: string[]) => void;
searchValue?: string;
onSearch?: (value: string) => void;
placeholder?: string;
allowSearch?: boolean;
renderEmptyState?: () => ReactNode;
renderValue?: (values: string[]) => ReactNode;
children: ReactNode;
}
export function MultiSelect({
value = [],
onChange,
searchValue,
onSearch,
placeholder = 'Select an item',
allowSearch,
renderEmptyState,
renderValue,
children,
}: MultiSelectProps) {
const displayValue = renderValue
? renderValue(value)
: value.length > 0
? value.join(', ')
: null;
return (
<MenuTrigger>
<Button
variant="outline"
className="w-full justify-between"
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayValue ?? placeholder}
</span>
<Icon rotate={90} size="sm" aria-hidden="true">
<ChevronRight />
</Icon>
</Button>
<Popover style={{ minWidth: 'var(--trigger-width)', maxWidth: 'var(--trigger-width)' }}>
<Column
gap="2"
padding="2"
border
borderRadius="md"
shadow="lg"
className="bg-surface-overlay"
>
{allowSearch && <SearchField value={searchValue} onSearch={onSearch} autoFocus />}
<List
selectionMode="multiple"
value={value}
onChange={onChange}
showCheckmark
renderEmptyState={renderEmptyState}
>
{children}
</List>
</Column>
</Popover>
</MenuTrigger>
);
}

View file

@ -8,6 +8,12 @@ export interface EventStatsData {
visitors: number; visitors: number;
visits: number; visits: number;
uniqueEvents: number; uniqueEvents: number;
comparison: {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
};
} }
type EventStatsApiResponse = { type EventStatsApiResponse = {

View file

@ -3,15 +3,29 @@ import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters'; import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters'; import { useFilterParameters } from '../useFilterParameters';
export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) { export function useWebsiteEventsSeriesQuery(
websiteId: string,
params?: { limit?: number },
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt, unit, timezone } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery({ return useQuery({
queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }], queryKey: [
'websites:events:series',
{ websiteId, startAt, endAt, unit, timezone, ...filters, ...params },
],
queryFn: () => queryFn: () =>
get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }), get(`/websites/${websiteId}/events/series`, {
startAt,
endAt,
unit,
timezone,
...filters,
...params,
}),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -1,91 +1,29 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FILTER_COLUMNS } from '@/lib/constants';
import { useNavigation } from './useNavigation'; import { useNavigation } from './useNavigation';
export function useFilterParameters() { export function useFilterParameters() {
const { const { query } = useNavigation();
query: {
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
},
} = useNavigation();
return useMemo(() => { return useMemo(() => {
const filterParams: Record<string, any> = {};
for (const key of Object.keys(query)) {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
filterParams[key] = query[key];
}
}
return { return {
path, ...filterParams,
referrer, search: query.search,
title, segment: query.segment,
query, cohort: query.cohort,
host, excludeBounce: query.excludeBounce,
os, match: query.match,
browser, page: query.page,
device, pageSize: query.pageSize,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search,
segment,
cohort,
excludeBounce,
}; };
}, [ }, [query]);
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
segment,
cohort,
excludeBounce,
]);
} }

View file

@ -14,6 +14,8 @@ export function useFilters() {
{ name: 'neq', type: 'string', label: t(labels.isNot) }, { name: 'neq', type: 'string', label: t(labels.isNot) },
{ name: 'c', type: 'string', label: t(labels.contains) }, { name: 'c', type: 'string', label: t(labels.contains) },
{ name: 'dnc', type: 'string', label: t(labels.doesNotContain) }, { name: 'dnc', type: 'string', label: t(labels.doesNotContain) },
{ name: 're', type: 'string', label: t(labels.regexMatch) },
{ name: 'nre', type: 'string', label: t(labels.regexNotMatch) },
{ name: 'i', type: 'array', label: t(labels.includes) }, { name: 'i', type: 'array', label: t(labels.includes) },
{ name: 'dni', type: 'array', label: t(labels.doesNotInclude) }, { name: 'dni', type: 'array', label: t(labels.doesNotInclude) },
{ name: 't', type: 'boolean', label: t(labels.isTrue) }, { name: 't', type: 'boolean', label: t(labels.isTrue) },
@ -36,6 +38,8 @@ export function useFilters() {
[OPERATORS.notSet]: t(labels.isNotSet), [OPERATORS.notSet]: t(labels.isNotSet),
[OPERATORS.contains]: t(labels.contains), [OPERATORS.contains]: t(labels.contains),
[OPERATORS.doesNotContain]: t(labels.doesNotContain), [OPERATORS.doesNotContain]: t(labels.doesNotContain),
[OPERATORS.regex]: t(labels.regexMatch),
[OPERATORS.notRegex]: t(labels.regexNotMatch),
[OPERATORS.true]: t(labels.true), [OPERATORS.true]: t(labels.true),
[OPERATORS.false]: t(labels.false), [OPERATORS.false]: t(labels.false),
[OPERATORS.greaterThan]: t(labels.greaterThan), [OPERATORS.greaterThan]: t(labels.greaterThan),
@ -47,7 +51,14 @@ export function useFilters() {
}; };
const typeFilters = { const typeFilters = {
string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain], string: [
OPERATORS.equals,
OPERATORS.notEquals,
OPERATORS.contains,
OPERATORS.doesNotContain,
OPERATORS.regex,
OPERATORS.notRegex,
],
array: [OPERATORS.contains, OPERATORS.doesNotContain], array: [OPERATORS.contains, OPERATORS.doesNotContain],
boolean: [OPERATORS.true, OPERATORS.false], boolean: [OPERATORS.true, OPERATORS.false],
number: [ number: [
@ -63,10 +74,11 @@ export function useFilters() {
}; };
const filters = Object.keys(query).reduce((arr, key) => { const filters = Object.keys(query).reduce((arr, key) => {
if (FILTER_COLUMNS[key]) { const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
let operator = 'eq'; let operator = 'eq';
let value = safeDecodeURIComponent(query[key]); let value = safeDecodeURIComponent(query[key]);
const label = fields.find(({ name }) => name === key)?.label; const label = fields.find(({ name }) => name === baseName)?.label;
const match = value.match(/^([a-z]+)\.(.*)/); const match = value.match(/^([a-z]+)\.(.*)/);
@ -77,6 +89,7 @@ export function useFilters() {
return arr.concat({ return arr.concat({
name: key, name: key,
type: baseName,
operator, operator,
value, value,
label, label,

View file

@ -1,5 +1,5 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { buildPath } from '@/lib/url'; import { buildPath } from '@/lib/url';
export function useNavigation() { export function useNavigation() {
@ -13,20 +13,29 @@ export function useNavigation() {
const [, boardId] = pathname.match(/\/boards\/([a-f0-9-]+)/) || []; const [, boardId] = pathname.match(/\/boards\/([a-f0-9-]+)/) || [];
const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams)); const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
const updateParams = (params?: Record<string, string | number>) => { const updateParams = useCallback(
return buildPath(pathname, { ...queryParams, ...params }); (params?: Record<string, string | number>) => {
}; return buildPath(pathname, { ...queryParams, ...params });
},
[pathname, queryParams],
);
const replaceParams = (params?: Record<string, string | number>) => { const replaceParams = useCallback(
return buildPath(pathname, params); (params?: Record<string, string | number>) => {
}; return buildPath(pathname, params);
},
[pathname],
);
const renderUrl = (path: string, params?: Record<string, string | number> | false) => { const renderUrl = useCallback(
return buildPath( (path: string, params?: Record<string, string | number> | false) => {
teamId ? `/teams/${teamId}${path}` : path, return buildPath(
params === false ? {} : { ...queryParams, ...params }, teamId ? `/teams/${teamId}${path}` : path,
); params === false ? {} : { ...queryParams, ...params },
}; );
},
[teamId, queryParams],
);
useEffect(() => { useEffect(() => {
setQueryParams(Object.fromEntries(searchParams)); setQueryParams(Object.fromEntries(searchParams));

View file

@ -3,6 +3,7 @@ import {
Column, Column,
Grid, Grid,
Icon, Icon,
Label,
List, List,
ListItem, ListItem,
ListSection, ListSection,
@ -12,6 +13,7 @@ import {
MenuTrigger, MenuTrigger,
Popover, Popover,
Row, Row,
Select,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { endOfDay, subMonths } from 'date-fns'; import { endOfDay, subMonths } from 'date-fns';
import type { Key } from 'react'; import type { Key } from 'react';
@ -24,11 +26,20 @@ export interface FieldFiltersProps {
websiteId: string; websiteId: string;
value?: { name: string; operator: string; value: string }[]; value?: { name: string; operator: string; value: string }[];
exclude?: string[]; exclude?: string[];
match?: string;
onChange?: (data: any) => void; onChange?: (data: any) => void;
onMatchChange?: (match: string) => void;
} }
export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) { export function FieldFilters({
const { t, messages } = useMessages(); websiteId,
value,
exclude = [],
match = 'all',
onChange,
onMatchChange,
}: FieldFiltersProps) {
const { t, labels, messages } = useMessages();
const { fields, groupLabels } = useFields(); const { fields, groupLabels } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6); const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
@ -48,24 +59,24 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
{} as Record<FieldGroup, typeof fields>, {} as Record<FieldGroup, typeof fields>,
); );
const updateFilter = (name: string, props: Record<string, any>) => { const updateFilter = (index: number, props: Record<string, any>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); onChange(value.map((filter, i) => (i === index ? { ...filter, ...props } : filter)));
}; };
const handleAdd = (name: Key) => { const handleAdd = (name: Key) => {
onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' })); onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
}; };
const handleChange = (name: string, value: Key) => { const handleChange = (index: number, val: Key) => {
updateFilter(name, { value }); updateFilter(index, { value: val });
}; };
const handleSelect = (name: string, operator: Key) => { const handleSelect = (index: number, operator: Key) => {
updateFilter(name, { operator }); updateFilter(index, { operator });
}; };
const handleRemove = (name: string) => { const handleRemove = (index: number) => {
onChange(value.filter(filter => filter.name !== name)); onChange(value.filter((_, i) => i !== index));
}; };
return ( return (
@ -87,9 +98,8 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
return ( return (
<MenuSection key={groupKey} title={label}> <MenuSection key={groupKey} title={label}>
{groupFields.map(field => { {groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return ( return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}> <MenuItem key={field.name} id={field.name}>
{field.filterLabel} {field.filterLabel}
</MenuItem> </MenuItem>
); );
@ -115,9 +125,8 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
return ( return (
<ListSection key={groupKey} title={label}> <ListSection key={groupKey} title={label}>
{groupFields.map(field => { {groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return ( return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}> <ListItem key={field.name} id={field.name}>
{field.filterLabel} {field.filterLabel}
</ListItem> </ListItem>
); );
@ -128,18 +137,29 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
</List> </List>
</Column> </Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}> <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
{value.map(filter => { {onMatchChange && (
<Row alignItems="center" gap>
<Column gap="1">
<Label>{t(labels.match)}</Label>
<Select value={match} onChange={onMatchChange} style={{ width: 150 }}>
<ListItem id="all">{t(labels.matchAll)}</ListItem>
<ListItem id="any">{t(labels.matchAny)}</ListItem>
</Select>
</Column>
</Row>
)}
{value.map((filter, index) => {
return ( return (
<FilterRecord <FilterRecord
key={filter.name} key={`${filter.name}-${index}`}
websiteId={websiteId} websiteId={websiteId}
type={filter.name} type={filter.name}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
{...filter} {...filter}
onSelect={handleSelect} onSelect={(_name, operator) => handleSelect(index, operator)}
onRemove={handleRemove} onRemove={() => handleRemove(index)}
onChange={handleChange} onChange={(_name, val) => handleChange(index, val)}
/> />
); );
})} })}

View file

@ -78,8 +78,13 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
/> />
)} )}
{filters.map(filter => { {filters.map(filter => {
const { name, label, operator, value } = filter; const { name, type, label, operator, value } = filter;
const paramValue = isSearchOperator(operator) ? value : formatValue(value, name); const paramValue = isSearchOperator(operator)
? value
: String(value)
.split(',')
.map(v => formatValue(v, type || name))
.join(', ');
return ( return (
<FilterItem <FilterItem

View file

@ -6,13 +6,18 @@ import { SegmentFilters } from '@/components/input/SegmentFilters';
export interface FilterEditFormProps { export interface FilterEditFormProps {
websiteId?: string; websiteId?: string;
onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void; onChange?: (params: {
filters: any[];
segment?: string;
cohort?: string;
match?: string;
}) => void;
onClose?: () => void; onClose?: () => void;
} }
export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) { export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
const { const {
query: { segment, cohort }, query: { segment, cohort, match },
pathname, pathname,
} = useNavigation(); } = useNavigation();
const { filters } = useFilters(); const { filters } = useFilters();
@ -20,6 +25,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
const [currentFilters, setCurrentFilters] = useState(filters); const [currentFilters, setCurrentFilters] = useState(filters);
const [currentSegment, setCurrentSegment] = useState(segment); const [currentSegment, setCurrentSegment] = useState(segment);
const [currentCohort, setCurrentCohort] = useState(cohort); const [currentCohort, setCurrentCohort] = useState(cohort);
const [currentMatch, setCurrentMatch] = useState<string>(match || 'all');
const { isMobile } = useMobile(); const { isMobile } = useMobile();
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
const excludeEvent = !pathname.endsWith('/events'); const excludeEvent = !pathname.endsWith('/events');
@ -28,6 +34,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
setCurrentFilters([]); setCurrentFilters([]);
setCurrentSegment(undefined); setCurrentSegment(undefined);
setCurrentCohort(undefined); setCurrentCohort(undefined);
setCurrentMatch('all');
}; };
const handleSave = () => { const handleSave = () => {
@ -35,6 +42,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
filters: currentFilters.filter(f => f.value), filters: currentFilters.filter(f => f.value),
segment: currentSegment, segment: currentSegment,
cohort: currentCohort, cohort: currentCohort,
match: currentMatch !== 'all' ? currentMatch : undefined,
}); });
onClose?.(); onClose?.();
}; };
@ -61,7 +69,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
<FieldFilters <FieldFilters
websiteId={websiteId} websiteId={websiteId}
value={currentFilters} value={currentFilters}
match={currentMatch}
onChange={setCurrentFilters} onChange={setCurrentFilters}
onMatchChange={setCurrentMatch}
exclude={ exclude={
excludeFilters excludeFilters
? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event']

View file

@ -23,6 +23,7 @@ export function TeamsButton() {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const { teamId, router } = useNavigation(); const { teamId, router } = useNavigation();
const { isPhone } = useMobile();
const team = user?.teams?.find(({ id }) => id === teamId); const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']); const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username; const label = teamId ? team?.name : user.username;
@ -52,7 +53,7 @@ export function TeamsButton() {
position="relative" position="relative"
gap gap
maxHeight="40px" maxHeight="40px"
minWidth="200px" minWidth={isPhone ? '100px' : '200px'}
maxWidth="200px" maxWidth="200px"
> >
<Icon>{teamId ? <Users /> : <User />}</Icon> <Icon>{teamId ? <Users /> : <User />}</Icon>

View file

@ -33,9 +33,10 @@ import { languages } from '@/lib/lang';
export interface UserButtonProps { export interface UserButtonProps {
showText?: boolean; showText?: boolean;
onClose?: () => void;
} }
export function UserButton({ showText = true }: UserButtonProps) { export function UserButton({ showText = true, onClose }: UserButtonProps) {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { cloudMode } = useConfig(); const { cloudMode } = useConfig();
const { t, labels } = useMessages(); const { t, labels } = useMessages();
@ -111,7 +112,7 @@ export function UserButton({ showText = true }: UserButtonProps) {
</TooltipTrigger> </TooltipTrigger>
<Popover placement="top start"> <Popover placement="top start">
<Column minWidth="200px"> <Column minWidth="200px">
<Menu autoFocus="last"> <Menu autoFocus="last" onAction={onClose}>
<MenuItem id="settings" href={getUrl('/settings')}> <MenuItem id="settings" href={getUrl('/settings')}>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Icon> <Icon>

View file

@ -40,10 +40,11 @@ export function WebsiteDateFilter({
updateParams({ updateParams({
date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
offset: undefined, offset: undefined,
page: 1,
}), }),
); );
} else { } else {
router.push(updateParams({ date, offset: undefined, unit: undefined })); router.push(updateParams({ date, offset: undefined, unit: undefined, page: 1 }));
} }
}; };

View file

@ -1,6 +1,6 @@
import { Checkbox, Row } from '@umami/react-zen'; import { Checkbox, Row } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useFilters, useMessages, useNavigation } from '@/components/hooks';
import { ListFilter } from '@/components/icons'; import { ListFilter } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { FilterEditForm } from '@/components/input/FilterEditForm'; import { FilterEditForm } from '@/components/input/FilterEditForm';
@ -15,17 +15,21 @@ export function WebsiteFilterButton({
}) { }) {
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const { updateParams, pathname, router, query } = useNavigation(); const { updateParams, pathname, router, query } = useNavigation();
const { filters: currentFilters } = useFilters();
const [excludeBounce, setExcludeBounce] = useState(!!query.excludeBounce); const [excludeBounce, setExcludeBounce] = useState(!!query.excludeBounce);
const isOverview = const isOverview =
/^\/teams\/[^/]+\/websites\/[^/]+$/.test(pathname) || /^\/share\/[^/]+$/.test(pathname); /^\/teams\/[^/]+\/websites\/[^/]+$/.test(pathname) || /^\/share\/[^/]+$/.test(pathname);
const handleChange = ({ filters, segment, cohort }: any) => { const handleChange = ({ filters, segment, cohort, match }: any) => {
const params = filtersArrayToObject(filters); const params = filtersArrayToObject(filters);
const cleared = Object.fromEntries(currentFilters.map(f => [f.name, undefined]));
const url = updateParams({ const url = updateParams({
...cleared,
...params, ...params,
segment, segment,
cohort, cohort,
match,
excludeBounce: excludeBounce ? 'true' : undefined, excludeBounce: excludeBounce ? 'true' : undefined,
}); });

View file

@ -198,6 +198,8 @@ export const labels: Record<string, string> = {
lessThanEquals: 'label.less-than-equals', lessThanEquals: 'label.less-than-equals',
contains: 'label.contains', contains: 'label.contains',
doesNotContain: 'label.does-not-contain', doesNotContain: 'label.does-not-contain',
regexMatch: 'label.regex-match',
regexNotMatch: 'label.regex-not-match',
includes: 'label.includes', includes: 'label.includes',
doesNotInclude: 'label.does-not-include', doesNotInclude: 'label.does-not-include',
before: 'label.before', before: 'label.before',
@ -303,6 +305,9 @@ export const labels: Record<string, string> = {
other: 'label.other', other: 'label.other',
boards: 'label.boards', boards: 'label.boards',
apply: 'label.apply', apply: 'label.apply',
match: 'label.match',
matchAll: 'label.match-all',
matchAny: 'label.match-any',
link: 'label.link', link: 'label.link',
links: 'label.links', links: 'label.links',
pixel: 'label.pixel', pixel: 'label.pixel',

View file

@ -15,15 +15,16 @@ import { generateTimeSeries } from '@/lib/date';
export interface EventsChartProps extends BarChartProps { export interface EventsChartProps extends BarChartProps {
websiteId: string; websiteId: string;
focusLabel?: string; focusLabel?: string;
limit?: number;
} }
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) {
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const { const {
dateRange: { startDate, endDate, unit }, dateRange: { startDate, endDate, unit },
} = useDateRange({ timezone: timezone }); } = useDateRange({ timezone: timezone });
const { locale, dateLocale } = useLocale(); const { locale, dateLocale } = useLocale();
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId, { limit });
const [label, setLabel] = useState<string>(focusLabel); const [label, setLabel] = useState<string>(focusLabel);
const chartData: any = useMemo(() => { const chartData: any = useMemo(() => {

View file

@ -0,0 +1,60 @@
import { colord } from 'colord';
import { useCallback, useMemo } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { useLocale } from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
export interface RevenueChartProps {
data: { x: string; t: string; y: number; count: number }[];
unit: string;
minDate: Date;
maxDate: Date;
currency: string;
}
export function RevenueChart({ data, unit, minDate, maxDate, currency }: RevenueChartProps) {
const { locale, dateLocale } = useLocale();
const chartData: any = useMemo(() => {
if (!data?.length) return { datasets: [] };
const map = data.reduce(
(obj, { x, t, y }) => {
if (!obj[x]) obj[x] = [];
obj[x].push({ x: t, y });
return obj;
},
{} as Record<string, { x: string; y: number }[]>,
);
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], minDate, maxDate, unit, dateLocale),
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, minDate, maxDate, unit, dateLocale]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
return (
<BarChart
chartData={chartData}
unit={unit}
minDate={minDate}
maxDate={maxDate}
currency={currency}
stacked={true}
renderXLabel={renderXLabel}
height="400px"
/>
);
}

View file

@ -70,47 +70,73 @@ function getSearchSQL(column: string, param: string = 'search'): string {
return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`; return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
} }
function mapFilter(column: string, operator: string, name: string, type: string = 'String') { function mapFilter(
const value = `{${name}:${type}}`; column: string,
operator: string,
name: string,
type: string = 'String',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{${param}:${type}}`;
switch (operator) { switch (operator) {
case OPERATORS.equals: case OPERATORS.equals:
return `${column} = ${value}`; return `${column} IN {${param}:Array(${type})}`;
case OPERATORS.notEquals: case OPERATORS.notEquals:
return `${column} != ${value}`; return `${column} NOT IN {${param}:Array(${type})}`;
case OPERATORS.contains: case OPERATORS.contains:
return `positionCaseInsensitive(${column}, ${value}) > 0`; return `positionCaseInsensitive(${column}, ${value}) > 0`;
case OPERATORS.doesNotContain: case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, ${value}) = 0`; return `positionCaseInsensitive(${column}, ${value}) = 0`;
case OPERATORS.regex:
return `match(${column}, ${value})`;
case OPERATORS.notRegex:
return `not match(${column}, ${value})`;
default: default:
return ''; return '';
} }
} }
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) { function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => { const { isCohort, cohortMatch, cohortActionName } = options;
const isCohort = options?.isCohort; const isOr = isCohort ? cohortMatch === 'any' : filters.match === 'any';
const orClauses: string[] = [];
const andClauses: string[] = [];
filtersObjectToArray(filters, options).forEach(({ name, column, operator, paramName }) => {
if (isCohort) { if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)]; column = FILTER_COLUMNS[name.slice('cohort_'.length)];
} }
if (column) { if (column) {
if (name === 'eventType') { const isAlwaysAnd = name === 'eventType' || (isCohort && name === cohortActionName);
arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`);
if (isAlwaysAnd) {
andClauses.push(
`and ${mapFilter(column, operator, name, name === 'eventType' ? 'UInt32' : 'String', paramName)}`,
);
} else if (isOr) {
orClauses.push(mapFilter(column, operator, name, 'String', paramName));
} else { } else {
arr.push(`and ${mapFilter(column, operator, name)}`); andClauses.push(`and ${mapFilter(column, operator, name, 'String', paramName)}`);
} }
if (name === 'referrer') { if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`); andClauses.push(`and referrer_domain != hostname`);
} }
} }
});
return arr; const parts: string[] = [];
}, []);
return query.join('\n'); if (orClauses.length > 0) {
parts.push(`and (\n ${orClauses.join('\n or ')}\n)`);
}
parts.push(...andClauses);
return parts.join('\n');
} }
function getCohortQuery(filters: Record<string, any>) { function getCohortQuery(filters: Record<string, any>) {
@ -118,7 +144,10 @@ function getCohortQuery(filters: Record<string, any>) {
return ''; return '';
} }
const filterQuery = getFilterQuery(filters, { isCohort: true }); const cohortMatch = filters.cohort_match;
const cohortActionName = filters.cohort_actionName;
const filterQuery = getFilterQuery(filters, { isCohort: true, cohortMatch, cohortActionName });
return `join ( return `join (
select distinct session_id select distinct session_id
@ -173,10 +202,19 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) { function getQueryParams(filters: Record<string, any>) {
return { return {
...filters, ...filters,
...filtersObjectToArray(filters).reduce((obj, { name, value }) => { ...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
if (name && value !== undefined) { const resolvedColumn =
obj[name] = value; column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
}
if (!resolvedColumn || !name || value === undefined) return obj;
const key = paramName ?? name;
obj[key] = ([OPERATORS.equals, OPERATORS.notEquals] as string[]).includes(operator)
? Array.isArray(value)
? value
: [value]
: value;
return obj; return obj;
}, {}), }, {}),

View file

@ -129,6 +129,8 @@ export const OPERATORS = {
notSet: 'ns', notSet: 'ns',
contains: 'c', contains: 'c',
doesNotContain: 'dnc', doesNotContain: 'dnc',
regex: 're',
notRegex: 'nre',
true: 't', true: 't',
false: 'f', false: 'f',
greaterThan: 'gt', greaterThan: 'gt',

View file

@ -9,10 +9,21 @@ export function parseFilterValue(param: any) {
const [, operator, value] = param.match(regex) || []; const [, operator, value] = param.match(regex) || [];
return { operator: operator || OPERATORS.equals, value: value || param }; const resolvedOperator = operator || OPERATORS.equals;
const resolvedValue = value ?? param;
if (resolvedOperator === OPERATORS.equals || resolvedOperator === OPERATORS.notEquals) {
return { operator: resolvedOperator, value: resolvedValue.split(',') };
}
return { operator: resolvedOperator, value: resolvedValue };
} }
return { operator: OPERATORS.equals, value: param }; if (Array.isArray(param)) {
return { operator: OPERATORS.equals, value: param };
}
return { operator: OPERATORS.equals, value: [param] };
} }
export function isEqualsOperator(operator: any) { export function isEqualsOperator(operator: any) {
@ -20,7 +31,12 @@ export function isEqualsOperator(operator: any) {
} }
export function isSearchOperator(operator: any) { export function isSearchOperator(operator: any) {
return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator); return [
OPERATORS.contains,
OPERATORS.doesNotContain,
OPERATORS.regex,
OPERATORS.notRegex,
].includes(operator);
} }
export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] { export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] {
@ -35,15 +51,23 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
return arr; return arr;
} }
const baseName = key.replace(/\d+$/, '');
const paramName = key !== baseName ? key : undefined;
if (filter?.name && filter?.value !== undefined) { if (filter?.name && filter?.value !== undefined) {
return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] }); return arr.concat({
...filter,
column: options?.columns?.[baseName] ?? FILTER_COLUMNS[baseName],
paramName: paramName ?? filter.paramName,
});
} }
const { operator, value } = parseFilterValue(filter); const { operator, value } = parseFilterValue(filter);
return arr.concat({ return arr.concat({
name: key, name: baseName,
column: options?.columns?.[key] ?? FILTER_COLUMNS[key], paramName,
column: options?.columns?.[baseName] ?? FILTER_COLUMNS[baseName],
operator, operator,
value, value,
prefix: options?.prefix, prefix: options?.prefix,
@ -52,10 +76,14 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
} }
export function filtersArrayToObject(filters: Filter[]) { export function filtersArrayToObject(filters: Filter[]) {
const nameCounts: Record<string, number> = {};
return filters.reduce((obj, filter: Filter) => { return filters.reduce((obj, filter: Filter) => {
const { name, operator, value } = filter; const { name, operator, value } = filter;
const count = nameCounts[name] ?? 0;
const key = count === 0 ? name : `${name}${count}`;
nameCounts[name] = count + 1;
obj[name] = `${operator}.${value}`; obj[key] = `${operator}.${Array.isArray(value) ? value.join(',') : value}`;
return obj; return obj;
}, {}); }, {});

View file

@ -71,8 +71,15 @@ function getSearchSQL(column: string, param: string = 'search'): string {
return `and ${column} ilike {{${param}}}`; return `and ${column} ilike {{${param}}}`;
} }
function mapFilter(column: string, operator: string, name: string, type: string = '') { function mapFilter(
const value = `{{${name}${type ? `::${type}` : ''}}}`; column: string,
operator: string,
name: string,
type: string = '',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{{${param}${type ? `::${type}` : ''}}}`;
if (name.startsWith('cohort_')) { if (name.startsWith('cohort_')) {
name = name.slice('cohort_'.length); name = name.slice('cohort_'.length);
@ -82,43 +89,64 @@ function mapFilter(column: string, operator: string, name: string, type: string
switch (operator) { switch (operator) {
case OPERATORS.equals: case OPERATORS.equals:
return `${table}.${column} = ${value}`; return `${table}.${column} = ANY(${value})`;
case OPERATORS.notEquals: case OPERATORS.notEquals:
return `${table}.${column} != ${value}`; return `${table}.${column} != ALL(${value})`;
case OPERATORS.contains: case OPERATORS.contains:
return `${table}.${column} ilike ${value}`; return `${table}.${column} ilike ${value}`;
case OPERATORS.doesNotContain: case OPERATORS.doesNotContain:
return `${table}.${column} not ilike ${value}`; return `${table}.${column} not ilike ${value}`;
case OPERATORS.regex:
return `${table}.${column} ~ ${value}`;
case OPERATORS.notRegex:
return `${table}.${column} !~ ${value}`;
default: default:
return ''; return '';
} }
} }
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string { function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
const query = filtersObjectToArray(filters, options).reduce( const { isCohort, cohortMatch, cohortActionName } = options;
(arr, { name, column, operator, prefix = '' }) => { const isOr = isCohort ? cohortMatch === 'any' : filters.match === 'any';
const isCohort = options?.isCohort; const orClauses: string[] = [];
const andClauses: string[] = [];
filtersObjectToArray(filters, options).forEach(
({ name, column, operator, prefix = '', paramName }) => {
if (isCohort) { if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)]; column = FILTER_COLUMNS[name.slice('cohort_'.length)];
} }
if (column) { if (column) {
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`); const clause = mapFilter(`${prefix}${column}`, operator, name, '', paramName);
const isAlwaysAnd = name === 'eventType' || (isCohort && name === cohortActionName);
if (isAlwaysAnd) {
andClauses.push(`and ${clause}`);
} else if (isOr) {
orClauses.push(clause);
} else {
andClauses.push(`and ${clause}`);
}
if (name === 'referrer') { if (name === 'referrer') {
arr.push( andClauses.push(
`and (website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') or website_event.referrer_domain is null)`, `and (website_event.referrer_domain != regexp_replace(website_event.hostname, '^www.', '') or website_event.referrer_domain is null)`,
); );
} }
} }
return arr;
}, },
[],
); );
return query.join('\n'); const parts: string[] = [];
if (orClauses.length > 0) {
parts.push(`and (\n ${orClauses.join('\n or ')}\n)`);
}
parts.push(...andClauses);
return parts.join('\n');
} }
function getCohortQuery(filters: QueryFilters = {}) { function getCohortQuery(filters: QueryFilters = {}) {
@ -126,7 +154,10 @@ function getCohortQuery(filters: QueryFilters = {}) {
return ''; return '';
} }
const filterQuery = getFilterQuery(filters, { isCohort: true }); const cohortMatch = (filters as any).cohort_match;
const cohortActionName = (filters as any).cohort_actionName;
const filterQuery = getFilterQuery(filters, { isCohort: true, cohortMatch, cohortActionName });
return `join return `join
(select distinct website_event.session_id (select distinct website_event.session_id
@ -177,10 +208,21 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) { function getQueryParams(filters: Record<string, any>) {
return { return {
...filters, ...filters,
...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => { ...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator) const resolvedColumn =
? `%${value}%` column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
: value;
if (!resolvedColumn) return obj;
const key = paramName ?? name;
if (([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator)) {
obj[key] = `%${value}%`;
} else if (([OPERATORS.equals, OPERATORS.notEquals] as Operator[]).includes(operator)) {
obj[key] = Array.isArray(value) ? value : [value];
} else {
obj[key] = value;
}
return obj; return obj;
}, {}), }, {}),
@ -188,9 +230,10 @@ function getQueryParams(filters: Record<string, any>) {
} }
function parseFilters(filters: Record<string, any>, options?: QueryOptions) { function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
const joinSession = Object.keys(filters).find(key => const joinSession = Object.keys(filters).find(key => {
['referrer', ...SESSION_COLUMNS].includes(key), const baseName = key.replace(/\d+$/, '');
); return ['referrer', ...SESSION_COLUMNS].includes(baseName);
});
const cohortFilters = Object.fromEntries( const cohortFilters = Object.fromEntries(
Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),

View file

@ -1,10 +1,112 @@
import { UmamiRedisClient } from '@umami/redis-client'; import debug from 'debug';
import { createClient, type RedisClientType } from 'redis';
const log = debug('umami:redis-client');
export const DELETED = '__DELETED__';
export const DEFAULT_TTL = 3600;
const logError = (err: unknown) => log(err);
class UmamiRedisClient {
url: string;
client: RedisClientType;
isConnected: boolean;
constructor(url: string) {
const client = createClient({ url }).on('error', logError);
this.url = url;
this.client = client as RedisClientType;
this.isConnected = false;
}
async connect() {
if (!this.isConnected) {
this.isConnected = true;
await this.client.connect();
log('Redis connected');
}
}
async get(key: string) {
await this.connect();
const data = await this.client.get(key);
try {
return JSON.parse(data as string);
} catch {
return null;
}
}
async set(key: string, value: any, time?: number) {
await this.connect();
const ttl = time && time > 0 ? time : DEFAULT_TTL;
return this.client.set(key, JSON.stringify(value), { EX: ttl });
}
async del(key: string) {
await this.connect();
return this.client.del(key);
}
async incr(key: string) {
await this.connect();
return this.client.incr(key);
}
async expire(key: string, seconds: number) {
await this.connect();
return this.client.expire(key, seconds);
}
async rateLimit(key: string, limit: number, seconds: number): Promise<boolean> {
await this.connect();
const res = await this.client.incr(key);
if (res === 1) {
await this.client.expire(key, seconds);
}
return res >= limit;
}
async fetch(key: string, query: () => Promise<any>, time?: number) {
const result = await this.get(key);
if (result === DELETED) return null;
if (!result && query) {
const data = await query();
if (data) {
await this.set(key, data, time);
}
return data;
}
return result;
}
async remove(key: string, soft = false) {
return soft ? this.set(key, DELETED) : this.del(key);
}
}
const REDIS = 'redis'; const REDIS = 'redis';
const enabled = !!process.env.REDIS_URL; const enabled = !!process.env.REDIS_URL;
function getClient() { function getClient() {
const redis = new UmamiRedisClient({ url: process.env.REDIS_URL }); const redis = new UmamiRedisClient(process.env.REDIS_URL);
const originalConnect = redis.connect.bind(redis); const originalConnect = redis.connect.bind(redis);
let connectPromise: Promise<void> | null = null; let connectPromise: Promise<void> | null = null;
@ -46,6 +148,6 @@ function getClient() {
return redis; return redis;
} }
const client = globalThis[REDIS] || getClient(); const client: UmamiRedisClient = globalThis[REDIS] || getClient();
export default { client, enabled }; export default { client, enabled };

View file

@ -1,7 +1,7 @@
import { startOfMonth, subMonths } from 'date-fns'; import { startOfMonth, subMonths } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { checkAuth } from '@/lib/auth'; import { checkAuth } from '@/lib/auth';
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants'; import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
import { fetchAccount, fetchWebsite } from '@/lib/load'; import { fetchAccount, fetchWebsite } from '@/lib/load';
import { filtersArrayToObject } from '@/lib/params'; import { filtersArrayToObject } from '@/lib/params';
@ -22,12 +22,20 @@ export async function parseRequest(
if (schema) { if (schema) {
const isGet = request.method === 'GET'; const isGet = request.method === 'GET';
const rawQuery = query;
const result = schema.safeParse(isGet ? query : body); const result = schema.safeParse(isGet ? query : body);
if (!result.success) { if (!result.success) {
error = () => badRequest(z.treeifyError(result.error)); error = () => badRequest(z.treeifyError(result.error));
} else if (isGet) { } else if (isGet) {
query = result.data; query = result.data;
// Re-add suffixed filter params (e.g., browser1, os2) stripped by Zod schema
for (const key of Object.keys(rawQuery)) {
if (/\d+$/.test(key) && !(key in query)) {
query[key] = rawQuery[key];
}
}
} else { } else {
body = result.data; body = result.data;
} }
@ -71,10 +79,10 @@ export function getRequestDateRange(query: Record<string, string>) {
export function getRequestFilters(query: Record<string, any>) { export function getRequestFilters(query: Record<string, any>) {
const result: Record<string, any> = {}; const result: Record<string, any> = {};
for (const key of Object.keys(FILTER_COLUMNS)) { for (const key of Object.keys(query)) {
const value = query[key]; const baseName = key.replace(/\d+$/, '');
if (value !== undefined) { if (baseName in FILTER_COLUMNS) {
result[key] = value; result[key] = query[key];
} }
} }
@ -107,6 +115,8 @@ export async function getQueryFilters(
const dateRange = getRequestDateRange(params); const dateRange = getRequestDateRange(params);
const filters = getRequestFilters(params); const filters = getRequestFilters(params);
let match = params?.match;
if (websiteId) { if (websiteId) {
await setWebsiteDate(websiteId, dateRange); await setWebsiteDate(websiteId, dateRange);
@ -115,6 +125,10 @@ export async function getQueryFilters(
?.parameters as Record<string, any>; ?.parameters as Record<string, any>;
Object.assign(filters, filtersArrayToObject(segmentParams.filters)); Object.assign(filters, filtersArrayToObject(segmentParams.filters));
if (segmentParams.match) {
match = segmentParams.match;
}
} }
if (params.cohort) { if (params.cohort) {
@ -130,7 +144,7 @@ export async function getQueryFilters(
cohortFilters.push({ cohortFilters.push({
name: `cohort_${cohortParams.action.type}`, name: `cohort_${cohortParams.action.type}`,
operator: 'eq', operator: OPERATORS.equals,
value: cohortParams.action.value, value: cohortParams.action.value,
}); });
@ -138,6 +152,10 @@ export async function getQueryFilters(
...filtersArrayToObject(cohortFilters), ...filtersArrayToObject(cohortFilters),
cohort_startDate: startDate, cohort_startDate: startDate,
cohort_endDate: endDate, cohort_endDate: endDate,
...(cohortParams.match && {
cohort_match: cohortParams.match,
cohort_actionName: `cohort_${cohortParams.action.type}`,
}),
}); });
} }
@ -149,6 +167,7 @@ export async function getQueryFilters(
return { return {
...dateRange, ...dateRange,
...filters, ...filters,
match,
page: params?.page, page: params?.page,
pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined, pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined,
orderBy: params?.orderBy, orderBy: params?.orderBy,

View file

@ -67,6 +67,7 @@ export const filterParams = {
cohort: z.uuid().optional(), cohort: z.uuid().optional(),
eventType: z.coerce.number().int().positive().optional(), eventType: z.coerce.number().int().positive().optional(),
excludeBounce: z.string().optional(), excludeBounce: z.string().optional(),
match: z.enum(['all', 'any']).optional(),
}; };
export const searchParams = { export const searchParams = {
@ -143,6 +144,8 @@ export const operatorParam = z.enum([
'ns', 'ns',
'c', 'c',
'dnc', 'dnc',
're',
'nre',
't', 't',
'f', 'f',
'gt', 'gt',
@ -227,6 +230,7 @@ export const revenueReportSchema = z.object({
unit: unitParam.optional(), unit: unitParam.optional(),
timezone: timezoneParam.optional(), timezone: timezoneParam.optional(),
currency: z.string(), currency: z.string(),
compare: z.enum(['prev', 'yoy']).optional(),
}), }),
}); });
@ -292,6 +296,7 @@ export const segmentParamSchema = z.object({
}), }),
) )
.optional(), .optional(),
match: z.enum(['all', 'any']).optional(),
dateRange: z.string().optional(), dateRange: z.string().optional(),
action: z action: z
.object({ .object({

View file

@ -27,10 +27,11 @@ export interface Auth {
export interface Filter { export interface Filter {
name: string; name: string;
operator: Operator; operator: Operator;
value: string; value: string | string[];
type?: string; type?: string;
column?: string; column?: string;
prefix?: string; prefix?: string;
paramName?: string;
} }
export interface DateRange { export interface DateRange {
@ -52,6 +53,8 @@ export interface QueryOptions {
limit?: number; limit?: number;
prefix?: string; prefix?: string;
isCohort?: boolean; isCohort?: boolean;
cohortMatch?: string;
cohortActionName?: string;
} }
export interface QueryFilters export interface QueryFilters
@ -92,6 +95,7 @@ export interface FilterParams {
cohort?: string; cohort?: string;
compare?: string; compare?: string;
excludeBounce?: boolean; excludeBounce?: boolean;
match?: 'all' | 'any';
} }
export interface SortParams { export interface SortParams {

View file

@ -6,6 +6,10 @@ import type { QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventStats'; const FUNCTION_NAME = 'getEventStats';
export interface EventStatsParameters {
limit?: number | string;
}
interface WebsiteEventMetric { interface WebsiteEventMetric {
x: string; x: string;
t: string; t: string;
@ -13,7 +17,7 @@ interface WebsiteEventMetric {
} }
export async function getEventStats( export async function getEventStats(
...args: [websiteId: string, filters: QueryFilters] ...args: [websiteId: string, parameters: EventStatsParameters, filters: QueryFilters]
): Promise<WebsiteEventMetric[]> { ): Promise<WebsiteEventMetric[]> {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(...args), [PRISMA]: () => relationalQuery(...args),
@ -21,7 +25,12 @@ export async function getEventStats(
}); });
} }
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(
websiteId: string,
parameters: EventStatsParameters,
filters: QueryFilters,
) {
const { limit } = parameters;
const { timezone = 'utc', unit = 'day' } = filters; const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma; const { rawQuery, getDateSQL, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
@ -30,6 +39,19 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
eventType: EVENT_TYPE.customEvent, eventType: EVENT_TYPE.customEvent,
}); });
const limitQuery = limit
? `and event_name in (
select event_name
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and event_type = 2
group by event_name
order by count(*) desc
limit ${limit}
)`
: '';
return rawQuery( return rawQuery(
` `
select select
@ -42,6 +64,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
${limitQuery}
group by 1, 2 group by 1, 2
order by 2 order by 2
`, `,
@ -52,8 +75,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
async function clickhouseQuery( async function clickhouseQuery(
websiteId: string, websiteId: string,
parameters: EventStatsParameters,
filters: QueryFilters, filters: QueryFilters,
): Promise<{ x: string; t: string; y: number }[]> { ): Promise<{ x: string; t: string; y: number }[]> {
const { limit } = parameters;
const { timezone = 'UTC', unit = 'day' } = filters; const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse; const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, queryParams } = parseFilters({
@ -62,6 +87,19 @@ async function clickhouseQuery(
eventType: EVENT_TYPE.customEvent, eventType: EVENT_TYPE.customEvent,
}); });
const limitQuery = limit
? `and event_name in (
select event_name
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
group by event_name
order by count(*) desc
limit ${limit}
)`
: '';
let sql = ''; let sql = '';
if (filterQuery || cohortQuery) { if (filterQuery || cohortQuery) {
@ -75,6 +113,7 @@ async function clickhouseQuery(
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery} ${filterQuery}
${limitQuery}
group by x, t group by x, t
order by t order by t
`; `;
@ -91,6 +130,7 @@ async function clickhouseQuery(
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}
${limitQuery}
) as g ) as g
group by x, t group by x, t
order by t order by t

View file

@ -91,6 +91,7 @@ async function relationalQuery(
when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
when referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end as "name", else '' end as "name",
session_id, session_id,
visit_id, visit_id,
@ -136,31 +137,33 @@ async function clickhouseQuery(
sum(if(t.c = 1, 1, 0)) as "bounces", sum(if(t.c = 1, 1, 0)) as "bounces",
sum(max_time-min_time) as "totaltime" sum(max_time-min_time) as "totaltime"
from ( from (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, select
case case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
case
when referrer_domain = '' and url_query = '' then 'direct' when referrer_domain = '' and url_query = '' then 'direct'
when multiSearchAny(url_query, [${toClickHouseStringArray( when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS, PAID_AD_PARAMS,
)}]) != 0 then 'paidAds' )}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate' when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS, SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS, SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social') )}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS, EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS, SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
VIDEO_DOMAINS, VIDEO_DOMAINS,
)}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video')
else '' end AS name, when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS name,
session_id, session_id,
visit_id, visit_id,
count(*) c, count(*) c,

View file

@ -61,6 +61,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
wwhen referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end AS x, else '' end AS x,
count(distinct session_id) y count(distinct session_id) y
from prefix from prefix
@ -90,31 +91,33 @@ async function clickhouseQuery(
const sql = ` const sql = `
WITH channels as ( WITH channels as (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, select
case case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
case
when referrer_domain = '' and url_query = '' then 'direct' when referrer_domain = '' and url_query = '' then 'direct'
when multiSearchAny(url_query, [${toClickHouseStringArray( when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS, PAID_AD_PARAMS,
)}]) != 0 then 'paidAds' )}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate' when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS, SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS, SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social') )}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS, EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS, SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
VIDEO_DOMAINS, VIDEO_DOMAINS,
)}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video')
else '' end AS x, when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS x,
count(distinct session_id) y count(distinct session_id) y
from website_event from website_event
${cohortQuery} ${cohortQuery}

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