diff --git a/db/clickhouse/migrations/10_add_session_recording.sql b/db/clickhouse/migrations/10_add_session_replay.sql similarity index 100% rename from db/clickhouse/migrations/10_add_session_recording.sql rename to db/clickhouse/migrations/10_add_session_replay.sql diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7da623381..3f713c1a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,8 +334,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - dist: {} - packages: '@ampproject/remapping@2.3.0': @@ -533,28 +531,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.2': resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -1318,105 +1312,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1617,28 +1595,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1693,42 +1667,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -2604,79 +2572,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -2841,28 +2796,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} diff --git a/prisma/migrations/19_add_session_recording/migration.sql b/prisma/migrations/19_add_session_replay/migration.sql similarity index 61% rename from prisma/migrations/19_add_session_recording/migration.sql rename to prisma/migrations/19_add_session_replay/migration.sql index c8720c0ee..1f69f3d16 100644 --- a/prisma/migrations/19_add_session_recording/migration.sql +++ b/prisma/migrations/19_add_session_replay/migration.sql @@ -25,3 +25,21 @@ CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("web CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id"); CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at"); CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index"); + +-- CreateTable +CREATE TABLE "session_replay_saved" ( + "saved_replay_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "website_id" UUID NOT NULL, + "visit_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "session_replay_saved_pkey" PRIMARY KEY ("saved_replay_id"), + CONSTRAINT "session_replay_saved_website_id_visit_id_key" UNIQUE ("website_id", "visit_id") +); + +-- CreateIndex +CREATE INDEX "session_replay_saved_website_id_idx" ON "session_replay_saved"("website_id"); +CREATE INDEX "session_replay_saved_visit_id_idx" ON "session_replay_saved"("visit_id"); +CREATE INDEX "session_replay_saved_website_id_created_at_idx" ON "session_replay_saved"("website_id", "created_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf0418eb5..00266ff67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,15 +78,16 @@ model Website { replayEnabled Boolean @default(false) @map("replay_enabled") replayConfig Json? @map("replay_config") - user User? @relation("user", fields: [userId], references: [id]) - createUser User? @relation("createUser", fields: [createdBy], references: [id]) - team Team? @relation(fields: [teamId], references: [id]) - eventData EventData[] - reports Report[] - revenue Revenue[] - segments Segment[] - sessionData SessionData[] - sessionReplays SessionReplay[] + user User? @relation("user", fields: [userId], references: [id]) + createUser User? @relation("createUser", fields: [createdBy], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) + eventData EventData[] + reports Report[] + revenue Revenue[] + segments Segment[] + sessionData SessionData[] + sessionReplays SessionReplay[] + sessionReplaysSaved SessionReplaySaved[] @@index([userId]) @@index([teamId]) @@ -383,3 +384,20 @@ model SessionReplay { @@index([sessionId, chunkIndex]) @@map("session_replay") } + +model SessionReplaySaved { + id String @id() @map("saved_replay_id") @db.Uuid + name String @db.VarChar(100) + websiteId String @map("website_id") @db.Uuid + visitId String @map("visit_id") @db.Uuid + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + website Website @relation(fields: [websiteId], references: [id]) + + @@unique([websiteId, visitId]) + @@index([websiteId]) + @@index([visitId]) + @@index([websiteId, createdAt]) + @@map("session_replay_saved") +} \ No newline at end of file diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 08a664e1e..d52028caa 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -270,6 +270,7 @@ "run-query": "Run query", "save": "Save", "save-cohort": "Save cohort", + "save-replay": "Save replay", "save-segment": "Save segment", "sample-rate": "Sample rate", "sample-size": "Sample size", @@ -344,6 +345,7 @@ "uniqueCustomers": "Unique Customers", "customers": "Customers", "orders": "Orders", + "saved": "Saved", "unknown": "Unknown", "untitled": "Untitled", "update": "Update", diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index 414fae0fb..6c473b592 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,17 +1,42 @@ 'use client'; -import { Column } from '@umami/react-zen'; +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { type Key, useState } from 'react'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { getItem, setItem } from '@/lib/storage'; import { ReplayModal } from './ReplayModal'; import { ReplaysDataTable } from './ReplaysDataTable'; +import { SavedReplaysDataTable } from './SavedReplaysDataTable'; + +const KEY_NAME = 'umami.replays.tab'; export function ReplaysPage({ websiteId }: { websiteId: string }) { + const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays'); + const { t, labels } = useMessages(); + + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + return ( - + + + {t(labels.replays)} + {t(labels.saved)} + + + + + + + + diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx index ce57c8538..d47371011 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -69,7 +69,7 @@ export function ReplaysTable({ ...props }: DataTableProps) { )} - + {(row: any) => } diff --git a/src/app/(main)/websites/[websiteId]/replays/SavedReplaysDataTable.tsx b/src/app/(main)/websites/[websiteId]/replays/SavedReplaysDataTable.tsx new file mode 100644 index 000000000..c682a2c38 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/replays/SavedReplaysDataTable.tsx @@ -0,0 +1,15 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useSavedReplaysQuery } from '@/components/hooks'; +import { SavedReplaysTable } from './SavedReplaysTable'; + +export function SavedReplaysDataTable({ websiteId }: { websiteId: string }) { + const queryResult = useSavedReplaysQuery(websiteId); + + return ( + + {({ data }) => { + return ; + }} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/replays/SavedReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/SavedReplaysTable.tsx new file mode 100644 index 000000000..b7eed40b9 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/replays/SavedReplaysTable.tsx @@ -0,0 +1,30 @@ +import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen'; +import { Play } from 'lucide-react'; +import { useMessages, useNavigation } from '@/components/hooks'; + +export function SavedReplaysTable({ ...props }: DataTableProps) { + const { t, labels } = useMessages(); + const { router, updateParams } = useNavigation(); + + return ( + + + {(row: any) => ( + + )} + + + + + {(row: any) => new Date(row.createdAt).toLocaleString()} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx index 3331bbbc0..3c3a68313 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx @@ -1,11 +1,19 @@ 'use client'; -import { Button, Column, Icon, Row, Text } from '@umami/react-zen'; -import { X } from 'lucide-react'; +import { Button, Column, Dialog, DialogTrigger, Icon, Popover, Row, Text } from '@umami/react-zen'; +import { Bookmark, X } from 'lucide-react'; +import { useState } from 'react'; import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo'; import { Avatar } from '@/components/common/Avatar'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks'; +import { + useMessages, + useReplayQuery, + useUpdateQuery, + useWebsiteSessionQuery, +} from '@/components/hooks'; +import { touch } from '@/components/hooks/useModified'; import { ReplayPlayer } from './ReplayPlayer'; +import { ReplaySaveForm } from './ReplaySaveForm'; export function ReplayPlayback({ websiteId, @@ -19,6 +27,15 @@ export function ReplayPlayback({ const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId); const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId); const { t, labels } = useMessages(); + const [isSaved, setIsSaved] = useState(null); + const { mutate } = useUpdateQuery(`/websites/${websiteId}/replays/${replayId}`); + + const saved = isSaved ?? replay?.isSaved ?? false; + + const handleUnsave = () => { + setIsSaved(false); + mutate({ isSaved: false }, { onSuccess: () => touch('replays') }); + }; return ( - {onClose && ( - - )} + + {saved ? ( + + ) : ( + + + + + {({ close }) => ( + { + setIsSaved(true); + touch('replays'); + }} + onClose={close} + /> + )} + + + + )} + {onClose && ( + + )} + )} diff --git a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplaySaveForm.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplaySaveForm.tsx new file mode 100644 index 000000000..29be76f65 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplaySaveForm.tsx @@ -0,0 +1,55 @@ +'use client'; +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +export function ReplaySaveForm({ + websiteId, + replayId, + onSave, + onClose, +}: { + websiteId: string; + replayId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { t, labels, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery( + `/websites/${websiteId}/replays/${replayId}`, + ); + + const handleSubmit = async (formData: { name: string }) => { + await mutateAsync( + { isSaved: true, name: formData.name }, + { + onSuccess: () => { + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + return ( +
+ + + + + + + {t(labels.save)} + + +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx index ff481f550..9c983dd04 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx @@ -36,7 +36,7 @@ export function SessionReplaysTable({ {(row: any) => formatDuration(row.duration || 0)} - + {(row: any) => } diff --git a/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts index eb030c163..281236d9f 100644 --- a/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts @@ -1,6 +1,12 @@ +import { z } from 'zod'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { canViewWebsite } from '@/permissions'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { + createReplaySaved, + deleteReplaySaved, + getReplaySaved, +} from '@/queries/prisma/sessionReplay'; import { getReplayChunks } from '@/queries/sql'; export async function GET( @@ -19,7 +25,10 @@ export async function GET( return unauthorized(); } - const chunks = await getReplayChunks(websiteId, replayId); + const [chunks, isSaved] = await Promise.all([ + getReplayChunks(websiteId, replayId), + getReplaySaved(websiteId, replayId), + ]); const allEvents = chunks.flatMap(chunk => chunk.events); const sessionId = chunks.length > 0 ? chunks[0].sessionId : null; @@ -33,5 +42,35 @@ export async function GET( endedAt, eventCount: allEvents.length, chunkCount: chunks.length, + isSaved, }); } + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string; replayId: string }> }, +) { + const schema = z.object({ + isSaved: z.boolean(), + name: z.string().max(100).optional(), + }); + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, replayId } = await params; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + if (body.isSaved) { + await createReplaySaved(websiteId, replayId, body.name ?? ''); + } else { + await deleteReplaySaved(websiteId, replayId); + } + + return json({ ok: true }); +} diff --git a/src/app/api/websites/[websiteId]/replays/saved/route.ts b/src/app/api/websites/[websiteId]/replays/saved/route.ts new file mode 100644 index 000000000..aca134d2c --- /dev/null +++ b/src/app/api/websites/[websiteId]/replays/saved/route.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSavedReplays } from '@/queries/prisma/sessionReplay'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getSavedReplays(websiteId, query); + + return json(data); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 5a1d17422..f7234bbd1 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -35,6 +35,7 @@ export * from './queries/useReportQuery'; export * from './queries/useReportsQuery'; export * from './queries/useResultQuery'; export * from './queries/useRevenueSessionsQuery'; +export * from './queries/useSavedReplaysQuery'; export * from './queries/useSessionActivityQuery'; export * from './queries/useSessionDataPropertiesQuery'; export * from './queries/useSessionDataQuery'; diff --git a/src/components/hooks/queries/useSavedReplaysQuery.ts b/src/components/hooks/queries/useSavedReplaysQuery.ts new file mode 100644 index 000000000..b6aef525c --- /dev/null +++ b/src/components/hooks/queries/useSavedReplaysQuery.ts @@ -0,0 +1,18 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useSavedReplaysQuery(websiteId: string) { + const { get } = useApi(); + const { modified } = useModified('replays'); + + return usePagedQuery({ + queryKey: ['replays:saved', { websiteId, modified }], + queryFn: pageParams => { + return get(`/websites/${websiteId}/replays/saved`, { + ...pageParams, + pageSize: 20, + }); + }, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 9d69bebcc..1e8073297 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -343,6 +343,7 @@ export const labels: Record = { version: 'label.version', saveSegment: 'label.save-segment', saveCohort: 'label.save-cohort', + saveReplay: 'label.save-replay', analysis: 'label.analysis', destinationUrl: 'label.destination-url', slug: 'label.slug', @@ -366,6 +367,7 @@ export const labels: Record = { sampleSize: 'label.sample-size', play: 'label.play', replays: 'label.replays', + saved: 'label.saved', replay: 'label.replay', replayId: 'label.replay-id', replayEnabled: 'label.replay-enabled', diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx index 9a606aecb..f47d8ac0c 100644 --- a/src/components/metrics/ListTable.tsx +++ b/src/components/metrics/ListTable.tsx @@ -72,7 +72,7 @@ export function ListTable({ alignItems="center" justifyContent="space-between" paddingLeft="2" - columns={`1fr ${showPercentage ? '100px' : '150px'}`} + columns={'1fr 100px'} > {title} @@ -117,7 +117,7 @@ const AnimatedRow = ({ return ( - + {change} diff --git a/src/queries/prisma/sessionReplay.ts b/src/queries/prisma/sessionReplay.ts index f102ac4aa..17b251672 100644 --- a/src/queries/prisma/sessionReplay.ts +++ b/src/queries/prisma/sessionReplay.ts @@ -1,5 +1,6 @@ import { uuid } from '@/lib/crypto'; import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; export interface CreateReplayChunkArgs { websiteId: string; @@ -62,3 +63,49 @@ export async function deleteReplaysByWebsite(websiteId: string) { where: { websiteId }, }); } + +export async function getReplaySaved(websiteId: string, visitId: string): Promise { + const record = await prisma.client.sessionReplaySaved.findUnique({ + where: { websiteId_visitId: { websiteId, visitId } }, + select: { id: true }, + }); + return record !== null; +} + +export async function createReplaySaved(websiteId: string, visitId: string, name: string) { + return prisma.client.sessionReplaySaved.create({ + data: { id: uuid(), websiteId, visitId, name }, + }); +} + +export async function updateReplaySaved(websiteId: string, visitId: string, name: string) { + return prisma.client.sessionReplaySaved.updateMany({ + where: { websiteId, visitId }, + data: { name }, + }); +} + +export async function deleteReplaySaved(websiteId: string, visitId: string) { + return prisma.client.sessionReplaySaved.deleteMany({ + where: { websiteId, visitId }, + }); +} + +export async function getSavedReplays(websiteId: string, filters: QueryFilters) { + const { search } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where = { + websiteId, + ...getSearchParameters(search, [{ name: 'contains' }, { visitId: 'contains' }]), + }; + + return pagedQuery( + 'sessionReplaySaved', + { + where, + orderBy: { createdAt: 'desc' }, + }, + filters, + ); +} diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 629f0d3c9..6c69431e7 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -136,6 +136,10 @@ export async function resetWebsite(websiteId: string) { return transaction( async tx => { + await tx.sessionReplaySaved.deleteMany({ + where: { websiteId }, + }); + await tx.sessionReplay.deleteMany({ where: { websiteId }, }); @@ -187,6 +191,10 @@ export async function deleteWebsite(websiteId: string) { return transaction( async tx => { + await tx.sessionReplaySaved.deleteMany({ + where: { websiteId }, + }); + await tx.sessionReplay.deleteMany({ where: { websiteId }, }); diff --git a/src/queries/sql/reports/getRetention.ts b/src/queries/sql/reports/getRetention.ts index 87b55e03d..6e1c60db9 100644 --- a/src/queries/sql/reports/getRetention.ts +++ b/src/queries/sql/reports/getRetention.ts @@ -132,7 +132,7 @@ async function clickhouseQuery( ), user_activities AS ( select distinct - website_event.session_id, + website_event.session_id as session_id, toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number from website_event join cohort_items