mirror of
https://github.com/umami-software/umami.git
synced 2026-02-25 06:55:35 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks failed
Node.js CI / build (push) Has been cancelled
Some checks failed
Node.js CI / build (push) Has been cancelled
# Conflicts: # src/lib/redis.ts
This commit is contained in:
commit
580008cd09
105 changed files with 1944 additions and 646 deletions
|
|
@ -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
16
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "الأعضاء",
|
||||
|
|
|
|||
|
|
@ -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": "Удзельнікі",
|
||||
|
|
|
|||
|
|
@ -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": "Членове",
|
||||
|
|
|
|||
|
|
@ -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": "সদস্যগণ",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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é",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Μέλη",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "اعضا",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "חברים",
|
||||
|
|
|
|||
|
|
@ -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": "सदस्यगण",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "メンバー",
|
||||
|
|
|
|||
|
|
@ -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": "សមាជិក",
|
||||
|
|
|
|||
|
|
@ -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": "멤버",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Гишүүд",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "အဖွဲ့ဝင်များ",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Участники",
|
||||
|
|
|
|||
|
|
@ -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": "සාමාජිකයින්",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "உறுப்பினர்கள்",
|
||||
|
|
|
|||
|
|
@ -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": "สมาชิก",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Учасники",
|
||||
|
|
|
|||
|
|
@ -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": "اراکین",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "成员",
|
||||
|
|
|
|||
|
|
@ -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": "成員",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
71
src/components/common/MultiSelect.tsx
Normal file
71
src/components/common/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@ export interface EventStatsData {
|
|||
visitors: number;
|
||||
visits: number;
|
||||
uniqueEvents: number;
|
||||
comparison: {
|
||||
events: number;
|
||||
visitors: number;
|
||||
visits: number;
|
||||
uniqueEvents: number;
|
||||
};
|
||||
}
|
||||
|
||||
type EventStatsApiResponse = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
60
src/components/metrics/RevenueChart.tsx
Normal file
60
src/components/metrics/RevenueChart.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}, {}),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ export const OPERATORS = {
|
|||
notSet: 'ns',
|
||||
contains: 'c',
|
||||
doesNotContain: 'dnc',
|
||||
regex: 're',
|
||||
notRegex: 'nre',
|
||||
true: 't',
|
||||
false: 'f',
|
||||
greaterThan: 'gt',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, {});
|
||||
|
|
|
|||
|
|
@ -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_')),
|
||||
|
|
|
|||
108
src/lib/redis.ts
108
src/lib/redis.ts
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue