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

16
pnpm-lock.yaml generated
View file

@ -44,9 +44,6 @@ importers:
'@umami/react-zen':
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))
'@umami/redis-client':
specifier: ^0.30.0
version: 0.30.0
bcryptjs:
specifier: ^3.0.2
version: 3.0.3
@ -173,6 +170,9 @@ importers:
react-window:
specifier: ^1.8.6
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:
specifier: ^3.3.0
version: 3.3.0
@ -2965,9 +2965,6 @@ packages:
react-aria-components: ^1.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:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
@ -10062,13 +10059,6 @@ snapshots:
- tailwindcss
- 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:
dependencies:
acorn: 8.15.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -51,6 +51,8 @@
"confirm": "Xác nhận",
"confirm-password": "Xác nhận mật khẩu",
"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",
"continue": "Tiếp tục",
"conversion": "Chuyển đổi",
@ -169,6 +171,9 @@
"manager": "Quản lý",
"max": "Tối đa",
"maximize": "Phóng to",
"match": "Khớp",
"match-all": "Tất cả",
"match-any": "Bất kỳ",
"medium": "Phương tiện",
"member": "Thành viên",
"members": "Các thành viên",

View file

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

View file

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

View file

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

View file

@ -49,7 +49,13 @@ export function MobileNav() {
<MobileMenuButton>
{({ close }) => {
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 &&
links.map(link => {
return (
@ -63,8 +69,8 @@ export function MobileNav() {
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
{isAdmin && <AdminNav onItemClick={close} />}
{isSettings && <SettingsNav onItemClick={close} />}
<Row onClick={close} style={{ marginTop: 'auto' }}>
<UserButton />
<Row style={{ marginTop: 'auto' }}>
<UserButton onClose={close} />
</Row>
</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 { 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 }) {
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const { pathname, renderUrl } = useNavigation();
const items = [
{
@ -37,6 +40,22 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
?.find(({ path }) => path && pathname.startsWith(path))?.id;
return (
<Column gap="2">
<Link href={renderUrl('/boards', false)} role="button" onClick={onItemClick}>
<TooltipTrigger delay={0}>
<Focusable>
<Row
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)}
@ -44,5 +63,6 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
);
}

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component
import { ROLES } from '@/lib/constants';
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: login } = useLoginQuery();
@ -30,7 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
};
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)}>
<TextField data-test="input-username" />
</FormField>

View file

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

View file

@ -1,19 +1,16 @@
import { Column, Grid, Row, Text } from '@umami/react-zen';
import classNames from 'classnames';
import { colord } from 'colord';
import { useCallback, useMemo, useState } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { GridRow } from '@/components/common/GridRow';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { CurrencySelect } from '@/components/input/CurrencySelect';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricLabel } from '@/components/metrics/MetricLabel';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
import { RevenueChart } from '@/components/metrics/RevenueChart';
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
@ -35,83 +32,49 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
};
const { t, labels } = useMessages();
const { locale, dateLocale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { compare, isAllTime } = useDateRange();
const { data, error, isLoading } = useResultQuery<any>('revenue', {
websiteId,
startDate,
endDate,
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(() => {
if (!data) return [];
const { sum, count, unique_count } = data.total;
const { sum, count, average, unique_count, comparison } = data.total;
return [
{
value: sum,
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),
formatValue: n => formatLongCurrency(n, currency),
change: comparison ? average - comparison.average : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
},
{
value: count,
label: t(labels.transactions),
change: comparison ? count - comparison.count : 0,
formatValue: formatLongNumber,
},
{
value: unique_count,
label: t(labels.uniqueCustomers),
change: comparison ? unique_count - comparison.unique_count : 0,
formatValue: formatLongNumber,
},
] 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 (
<Column gap>
@ -122,37 +85,119 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{data && (
<Column gap>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
{metrics?.map(({ label, value, change, formatValue }) => {
return (
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
<MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
);
})}
</MetricsBar>
<Panel>
<BarChart
chartData={chartData}
<RevenueChart
data={data.chart}
unit={unit}
minDate={startDate}
maxDate={endDate}
unit={unit}
stacked={true}
currency={currency}
renderXLabel={renderXLabel}
height="400px"
/>
</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.country)}
title={t(labels.referrer)}
metric={t(labels.revenue)}
data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
data={data?.referrer.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}))}
}),
)}
currency={currency}
renderLabel={renderCountryName}
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>
)}
</LoadingPanel>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,11 @@
import { getCompareDate } from '@/lib/date';
import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
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) {
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 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(),
unit: unitParam.optional(),
timezone: timezoneParam,
limit: z.coerce.number().optional(),
...filterParams,
});
@ -29,9 +30,10 @@ export async function GET(
return unauthorized();
}
const { limit } = query;
const filters = await getQueryFilters(query, websiteId);
const data = await getEventStats(websiteId, filters);
const data = await getEventStats(websiteId, { limit }, filters);
return json(data);
}

View file

@ -1,3 +1,4 @@
import { getCompareDate } from '@/lib/date';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, withDateRange } from '@/lib/schema';
@ -28,5 +29,17 @@ export async function GET(
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 />
</Icon>
<Heading>umami</Heading>
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} style={{ minWidth: 300 }}>
<FormField
label={t(labels.username)}
data-test="input-username"

View file

@ -56,7 +56,7 @@ export function DataGrid({
(page: number) => {
router.push(updateParams({ search, page }));
},
[search],
[search, updateParams],
);
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 { Empty } from '@/components/common/Empty';
import { MultiSelect } from '@/components/common/MultiSelect';
import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks';
import { X } from '@/components/icons';
import { isSearchOperator } from '@/lib/params';
@ -12,7 +23,7 @@ export interface FilterRecordProps {
endDate: Date;
name: string;
operator: string;
value: string;
value: string | string[];
onSelect?: (name: string, value: any) => void;
onRemove?: (name: string) => void;
onChange?: (name: string, value: string) => void;
@ -31,7 +42,8 @@ export function FilterRecord({
onChange,
}: FilterRecordProps) {
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 { formatValue } = useFormat();
const { data, isLoading } = useWebsiteValuesQuery({
@ -53,10 +65,15 @@ export function FilterRecord({
};
const handleSelectValue = (value: string) => {
setSelected(value);
setSelected([value]);
onChange?.(name, value);
};
const handleMultiSelectValue = (values: string[]) => {
setSelected(values);
onChange?.(name, values.join(','));
};
return (
<Column>
<Label>{fields.find(f => f.name === name)?.label}</Label>
@ -72,26 +89,30 @@ export function FilterRecord({
))}
</Select>
{isSearch && (
<TextField value={selected} defaultValue={selected} onChange={handleSelectValue} />
<TextField
value={selected[0] || ''}
defaultValue={selected[0] || ''}
onChange={handleSelectValue}
/>
)}
{!isSearch && (
<Select
<MultiSelect
value={selected}
onChange={handleSelectValue}
onChange={handleMultiSelectValue}
searchValue={search}
onSearch={handleSearch}
isLoading={isLoading}
listProps={{ renderEmptyState: () => <Empty /> }}
renderValue={values =>
values.length > 0 ? values.map(v => formatValue(v, type)).join(', ') : undefined
}
renderEmptyState={() => (isLoading ? <Loading icon="dots" /> : <Empty />)}
allowSearch
>
{items?.map(({ value }) => {
return (
{items.map(({ value }) => (
<ListItem key={value} id={value}>
{formatValue(value, type)}
</ListItem>
);
})}
</Select>
))}
</MultiSelect>
)}
</Grid>
<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;
visits: number;
uniqueEvents: number;
comparison: {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
};
}
type EventStatsApiResponse = {

View file

@ -3,15 +3,29 @@ import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
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 { startAt, endAt, unit, timezone } = useDateParameters();
const filters = useFilterParameters();
return useQuery({
queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }],
queryKey: [
'websites:events:series',
{ websiteId, startAt, endAt, unit, timezone, ...filters, ...params },
],
queryFn: () =>
get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }),
get(`/websites/${websiteId}/events/series`, {
startAt,
endAt,
unit,
timezone,
...filters,
...params,
}),
enabled: !!websiteId,
...options,
});

View file

@ -1,91 +1,29 @@
import { useMemo } from 'react';
import { FILTER_COLUMNS } from '@/lib/constants';
import { useNavigation } from './useNavigation';
export function useFilterParameters() {
const {
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();
const { query } = useNavigation();
return useMemo(() => {
return {
path,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search,
segment,
cohort,
excludeBounce,
};
}, [
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,
]);
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 {
...filterParams,
search: query.search,
segment: query.segment,
cohort: query.cohort,
excludeBounce: query.excludeBounce,
match: query.match,
page: query.page,
pageSize: query.pageSize,
};
}, [query]);
}

View file

@ -14,6 +14,8 @@ export function useFilters() {
{ name: 'neq', type: 'string', label: t(labels.isNot) },
{ name: 'c', type: 'string', label: t(labels.contains) },
{ 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: 'dni', type: 'array', label: t(labels.doesNotInclude) },
{ name: 't', type: 'boolean', label: t(labels.isTrue) },
@ -36,6 +38,8 @@ export function useFilters() {
[OPERATORS.notSet]: t(labels.isNotSet),
[OPERATORS.contains]: t(labels.contains),
[OPERATORS.doesNotContain]: t(labels.doesNotContain),
[OPERATORS.regex]: t(labels.regexMatch),
[OPERATORS.notRegex]: t(labels.regexNotMatch),
[OPERATORS.true]: t(labels.true),
[OPERATORS.false]: t(labels.false),
[OPERATORS.greaterThan]: t(labels.greaterThan),
@ -47,7 +51,14 @@ export function useFilters() {
};
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],
boolean: [OPERATORS.true, OPERATORS.false],
number: [
@ -63,10 +74,11 @@ export function useFilters() {
};
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 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]+)\.(.*)/);
@ -77,6 +89,7 @@ export function useFilters() {
return arr.concat({
name: key,
type: baseName,
operator,
value,
label,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -40,10 +40,11 @@ export function WebsiteDateFilter({
updateParams({
date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
offset: undefined,
page: 1,
}),
);
} 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 { useState } from 'react';
import { useMessages, useNavigation } from '@/components/hooks';
import { useFilters, useMessages, useNavigation } from '@/components/hooks';
import { ListFilter } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { FilterEditForm } from '@/components/input/FilterEditForm';
@ -15,17 +15,21 @@ export function WebsiteFilterButton({
}) {
const { t, labels } = useMessages();
const { updateParams, pathname, router, query } = useNavigation();
const { filters: currentFilters } = useFilters();
const [excludeBounce, setExcludeBounce] = useState(!!query.excludeBounce);
const isOverview =
/^\/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 cleared = Object.fromEntries(currentFilters.map(f => [f.name, undefined]));
const url = updateParams({
...cleared,
...params,
segment,
cohort,
match,
excludeBounce: excludeBounce ? 'true' : undefined,
});

View file

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

View file

@ -15,15 +15,16 @@ import { generateTimeSeries } from '@/lib/date';
export interface EventsChartProps extends BarChartProps {
websiteId: string;
focusLabel?: string;
limit?: number;
}
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) {
const { timezone } = useTimezone();
const {
dateRange: { startDate, endDate, unit },
} = useDateRange({ timezone: timezone });
const { locale, dateLocale } = useLocale();
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId, { limit });
const [label, setLabel] = useState<string>(focusLabel);
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`;
}
function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
const value = `{${name}:${type}}`;
function mapFilter(
column: string,
operator: string,
name: string,
type: string = 'String',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{${param}:${type}}`;
switch (operator) {
case OPERATORS.equals:
return `${column} = ${value}`;
return `${column} IN {${param}:Array(${type})}`;
case OPERATORS.notEquals:
return `${column} != ${value}`;
return `${column} NOT IN {${param}:Array(${type})}`;
case OPERATORS.contains:
return `positionCaseInsensitive(${column}, ${value}) > 0`;
case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, ${value}) = 0`;
case OPERATORS.regex:
return `match(${column}, ${value})`;
case OPERATORS.notRegex:
return `not match(${column}, ${value})`;
default:
return '';
}
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
const isCohort = options?.isCohort;
const { isCohort, cohortMatch, cohortActionName } = options;
const isOr = isCohort ? cohortMatch === 'any' : filters.match === 'any';
const orClauses: string[] = [];
const andClauses: string[] = [];
filtersObjectToArray(filters, options).forEach(({ name, column, operator, paramName }) => {
if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)];
}
if (column) {
if (name === 'eventType') {
arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`);
const isAlwaysAnd = name === 'eventType' || (isCohort && name === cohortActionName);
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 {
arr.push(`and ${mapFilter(column, operator, name)}`);
andClauses.push(`and ${mapFilter(column, operator, name, 'String', paramName)}`);
}
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>) {
@ -118,7 +144,10 @@ function getCohortQuery(filters: Record<string, any>) {
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 (
select distinct session_id
@ -173,10 +202,19 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) {
return {
...filters,
...filtersObjectToArray(filters).reduce((obj, { name, value }) => {
if (name && value !== undefined) {
obj[name] = value;
}
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
const resolvedColumn =
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;
}, {}),

View file

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

View file

@ -9,18 +9,34 @@ export function parseFilterValue(param: any) {
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 };
}
if (Array.isArray(param)) {
return { operator: OPERATORS.equals, value: param };
}
return { operator: OPERATORS.equals, value: [param] };
}
export function isEqualsOperator(operator: any) {
return [OPERATORS.equals, OPERATORS.notEquals].includes(operator);
}
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[] {
@ -35,15 +51,23 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
return arr;
}
const baseName = key.replace(/\d+$/, '');
const paramName = key !== baseName ? key : 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);
return arr.concat({
name: key,
column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
name: baseName,
paramName,
column: options?.columns?.[baseName] ?? FILTER_COLUMNS[baseName],
operator,
value,
prefix: options?.prefix,
@ -52,10 +76,14 @@ export function filtersObjectToArray(filters: QueryFilters, options: QueryOption
}
export function filtersArrayToObject(filters: Filter[]) {
const nameCounts: Record<string, number> = {};
return filters.reduce((obj, filter: 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;
}, {});

View file

@ -71,8 +71,15 @@ function getSearchSQL(column: string, param: string = 'search'): string {
return `and ${column} ilike {{${param}}}`;
}
function mapFilter(column: string, operator: string, name: string, type: string = '') {
const value = `{{${name}${type ? `::${type}` : ''}}}`;
function mapFilter(
column: string,
operator: string,
name: string,
type: string = '',
paramName?: string,
) {
const param = paramName ?? name;
const value = `{{${param}${type ? `::${type}` : ''}}}`;
if (name.startsWith('cohort_')) {
name = name.slice('cohort_'.length);
@ -82,43 +89,64 @@ function mapFilter(column: string, operator: string, name: string, type: string
switch (operator) {
case OPERATORS.equals:
return `${table}.${column} = ${value}`;
return `${table}.${column} = ANY(${value})`;
case OPERATORS.notEquals:
return `${table}.${column} != ${value}`;
return `${table}.${column} != ALL(${value})`;
case OPERATORS.contains:
return `${table}.${column} ilike ${value}`;
case OPERATORS.doesNotContain:
return `${table}.${column} not ilike ${value}`;
case OPERATORS.regex:
return `${table}.${column} ~ ${value}`;
case OPERATORS.notRegex:
return `${table}.${column} !~ ${value}`;
default:
return '';
}
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
const query = filtersObjectToArray(filters, options).reduce(
(arr, { name, column, operator, prefix = '' }) => {
const isCohort = options?.isCohort;
const { isCohort, cohortMatch, cohortActionName } = options;
const isOr = isCohort ? cohortMatch === 'any' : filters.match === 'any';
const orClauses: string[] = [];
const andClauses: string[] = [];
filtersObjectToArray(filters, options).forEach(
({ name, column, operator, prefix = '', paramName }) => {
if (isCohort) {
column = FILTER_COLUMNS[name.slice('cohort_'.length)];
}
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') {
arr.push(
andClauses.push(
`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 = {}) {
@ -126,7 +154,10 @@ function getCohortQuery(filters: QueryFilters = {}) {
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
(select distinct website_event.session_id
@ -177,10 +208,21 @@ function getDateQuery(filters: Record<string, any>) {
function getQueryParams(filters: Record<string, any>) {
return {
...filters,
...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => {
obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator)
? `%${value}%`
: value;
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
const resolvedColumn =
column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
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;
}, {}),
@ -188,9 +230,10 @@ function getQueryParams(filters: Record<string, any>) {
}
function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
const joinSession = Object.keys(filters).find(key =>
['referrer', ...SESSION_COLUMNS].includes(key),
);
const joinSession = Object.keys(filters).find(key => {
const baseName = key.replace(/\d+$/, '');
return ['referrer', ...SESSION_COLUMNS].includes(baseName);
});
const cohortFilters = Object.fromEntries(
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 enabled = !!process.env.REDIS_URL;
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);
let connectPromise: Promise<void> | null = null;
@ -46,6 +148,6 @@ function getClient() {
return redis;
}
const client = globalThis[REDIS] || getClient();
const client: UmamiRedisClient = globalThis[REDIS] || getClient();
export default { client, enabled };

View file

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

View file

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

View file

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

View file

@ -6,6 +6,10 @@ import type { QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventStats';
export interface EventStatsParameters {
limit?: number | string;
}
interface WebsiteEventMetric {
x: string;
t: string;
@ -13,7 +17,7 @@ interface WebsiteEventMetric {
}
export async function getEventStats(
...args: [websiteId: string, filters: QueryFilters]
...args: [websiteId: string, parameters: EventStatsParameters, filters: QueryFilters]
): Promise<WebsiteEventMetric[]> {
return runQuery({
[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 { rawQuery, getDateSQL, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
@ -30,6 +39,19 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
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(
`
select
@ -42,6 +64,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery}
${limitQuery}
group by 1, 2
order by 2
`,
@ -52,8 +75,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
async function clickhouseQuery(
websiteId: string,
parameters: EventStatsParameters,
filters: QueryFilters,
): Promise<{ x: string; t: string; y: number }[]> {
const { limit } = parameters;
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({
@ -62,6 +87,19 @@ async function clickhouseQuery(
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 = '';
if (filterQuery || cohortQuery) {
@ -75,6 +113,7 @@ async function clickhouseQuery(
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery}
${limitQuery}
group by x, t
order by t
`;
@ -91,6 +130,7 @@ async function clickhouseQuery(
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
${limitQuery}
) as g
group by x, 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', 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 referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end as "name",
session_id,
visit_id,
@ -136,30 +137,32 @@ async function clickhouseQuery(
sum(if(t.c = 1, 1, 0)) as "bounces",
sum(max_time-min_time) as "totaltime"
from (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
select
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 multiSearchAny(url_query, [${toClickHouseStringArray(
when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS,
)}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
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')
when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS name,
session_id,
visit_id,

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', 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')
wwhen referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end AS x,
count(distinct session_id) y
from prefix
@ -90,30 +91,32 @@ async function clickhouseQuery(
const sql = `
WITH channels as (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
select
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 multiSearchAny(url_query, [${toClickHouseStringArray(
when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS,
)}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
)}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
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')
when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS x,
count(distinct session_id) y
from website_event

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