feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links
Adds the read-side Umami integration queued in last week's website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`): - Realtime panel polls Umami at 5s intervals; world map renders visitor origins via echarts + `public/world-map/echarts-world.json` topo. - Sessions list + session-detail-sheet drill-down (per-session event timeline pulled from `/api/v1/website-analytics`). - Weekly heatmap (day-of-week × hour-of-day) for engagement timing. - Metric-detail pages under `/[portSlug]/website-analytics/[metric]` for pageviews / referrers / events deep-dives. - Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF beacon backed by `email_open_tracking` (migration 0076); resolves inline on render in inbox. - Tracked-link redirect: `/q/[slug]` routes through `tracked_links` (migration 0077) and forwards to the canonical destination after logging the click. - Dashboard `website-glance-tile` now reads from the live Umami service instead of placeholder data. Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`, `@types/topojson-client`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
189
docs/umami-api-capabilities.md
Normal file
189
docs/umami-api-capabilities.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Umami v2 / v3 API capabilities — reference for flesh-out planning
|
||||||
|
|
||||||
|
**Verified against:** analytics.portnimara.com (Umami v3.1.0), 2026-05-19.
|
||||||
|
**Auth:** username/password → JWT via `POST /api/auth/login`, Bearer on every request, 1h TTL (we cache 55min).
|
||||||
|
**Companion code:** `src/lib/services/umami.service.ts` (currently wraps stats/pageviews/metrics/active).
|
||||||
|
|
||||||
|
Endpoints below are listed by topic area, with what we currently use, what's available but unused, and where it could plug into the CRM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Stats & traffic snapshots — `/api/websites/:id/stats`
|
||||||
|
|
||||||
|
**Currently used.** Returns the flat aggregate over the requested window plus a `comparison` block for the prior window of equal length.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pageviews": 2081, "visitors": 726, "visits": 872,
|
||||||
|
"bounces": 457, "totaltime": 109519,
|
||||||
|
"comparison": { "pageviews": 1935, "visitors": 642, ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unused fields we could surface:**
|
||||||
|
|
||||||
|
- `totaltime` — total seconds on site → derive avg session time (`totaltime / visits`).
|
||||||
|
- `bounces / visits` → bounce-rate KPI.
|
||||||
|
- Period-over-period deltas (already wired for trend arrows, but the _full_ comparison object has more we could use for a "what changed since last period" panel).
|
||||||
|
|
||||||
|
**Filters supported** (per Umami docs, mostly untested by us): `url`, `referrer`, `title`, `query`, `event`, `host`, `os`, `browser`, `device`, `country`, `region`, `city` — meaning every stats call can be sliced. **Big unlock:** show stats for a specific landing-page URL on the berth detail (e.g. `/berths/A12` stats), or filter by referrer to see which channels drove signed EOIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Time-series — `/api/websites/:id/pageviews`
|
||||||
|
|
||||||
|
**Currently used** for the trend chart. Returns `{pageviews: [{x, y}], sessions?: [{x, y}]}` (sessions only when `compare` is requested).
|
||||||
|
|
||||||
|
**Parameters:** `startAt`, `endAt`, `unit` (`year|month|day|hour`), `timezone`, `compare` (untapped), `filters` (untapped).
|
||||||
|
|
||||||
|
**Unused:** `compare=prev` gives the same series for the previous period — could power a dual-line "vs last period" overlay on the chart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Top-N metrics — `/api/websites/:id/metrics`
|
||||||
|
|
||||||
|
**Currently used** for Top Pages / Referrers / Countries (limit 10). Returns `[{x, y}]`.
|
||||||
|
|
||||||
|
**Available `type` values** (we surface 4, Umami offers 17):
|
||||||
|
|
||||||
|
| Type | What it returns | CRM use case |
|
||||||
|
| --------------------------- | -------------------------- | --------------------------------------------------------- |
|
||||||
|
| `path` | Top URLs | ✅ Already shown (we mis-typed as `url`, now fixed) |
|
||||||
|
| `referrer` | Top referring sites | ✅ Already shown |
|
||||||
|
| `country` | Visitors by country | ✅ Already shown |
|
||||||
|
| `browser` / `os` / `device` | Tech breakdown | Not surfaced — useful for "is mobile traffic converting?" |
|
||||||
|
| `region` / `city` | Geographic drill-down | Strong fit for marina marketing |
|
||||||
|
| `language` | Visitor browser language | Could feed i18n decisions |
|
||||||
|
| `screen` | Resolution | Low value |
|
||||||
|
| `event` | Top custom events | Big unlock — see §6 below |
|
||||||
|
| `tag` | Event tags | Same |
|
||||||
|
| `query` | Top URL query strings | UTM-debug surface |
|
||||||
|
| `entry` / `exit` | First/last page in session | Funnel analysis |
|
||||||
|
| `title` | Top page titles (vs paths) | Better labels for non-slug URLs |
|
||||||
|
| `hostname` | Multi-domain sites | Probably N/A |
|
||||||
|
| `distinctId` | Custom user identifiers | If we ever pipe CRM user IDs into Umami |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Live visitors — `/api/websites/:id/active`
|
||||||
|
|
||||||
|
**Currently used** for the green-dot "N active right now" indicator. Returns `{visitors: number}` (last-5-min count).
|
||||||
|
|
||||||
|
**Alternative for richer realtime:** `/api/realtime/:websiteId` (live realtime feed) returns far more — current top URLs being viewed, current top countries, recent event stream, a 30-minute time-series, totals, plus a `timestamp` you can poll against. We could surface a "live" panel on the dashboard showing the most-viewed pages right now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sessions API — `/api/websites/:id/sessions/*`
|
||||||
|
|
||||||
|
**Not currently used.** Multiple endpoints worth integrating:
|
||||||
|
|
||||||
|
- `GET /sessions` — list every session in a range with full device/geo/visits/views columns. Pageable. Could power a "recent visitors" surface — see who's browsing the berth detail pages right now.
|
||||||
|
- `GET /sessions/stats` — summary aggregate (pageviews, visitors, visits, countries, events) keyed by session.
|
||||||
|
- `GET /sessions/:sessionId` — drill into a single session: device, OS, browser, country, subdivision, city, screen, language, firstAt, lastAt, visits, views, events, totaltime.
|
||||||
|
- `GET /sessions/:sessionId/activity` — full event timeline for one session (urlPath, eventName, referrerDomain, timestamps).
|
||||||
|
- `GET /sessions/:sessionId/properties` — custom session properties (email, name, etc. — if Umami's `identify()` is called from the marketing site).
|
||||||
|
- `GET /session-data/properties` + `/session-data/values` — aggregate custom session properties.
|
||||||
|
- `GET /sessions/weekly` — heatmap of session count by hour-of-week. Direct fit for an "engagement heatmap" widget.
|
||||||
|
|
||||||
|
**Big unlock:** if marketing site calls `umami.identify({email})` after EOI form submit, sessions can be linked back to a specific client. We could then show "this client's website journey" on their CRM detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Events API — `/api/websites/:id/events/*`
|
||||||
|
|
||||||
|
**Not currently used.** Umami auto-tracks pageviews; custom events are fired explicitly (e.g. button clicks, form submits, video plays). Endpoints:
|
||||||
|
|
||||||
|
- `GET /events` — list custom events in a range.
|
||||||
|
- `GET /events/stats` — totals.
|
||||||
|
- `GET /events/series` — time-series per event.
|
||||||
|
- `GET /event-data/*` — aggregate over event payload properties.
|
||||||
|
|
||||||
|
**High-leverage CRM use cases:**
|
||||||
|
|
||||||
|
- Fire an event on the marketing site when someone clicks "Inquire about berth A12" → CRM Activity feed shows it in real-time on the inquiry record.
|
||||||
|
- Fire an event when someone downloads a brochure → see which brochures convert.
|
||||||
|
- Fire an event on EOI form-step completions → drop-off funnel analysis.
|
||||||
|
|
||||||
|
We'd need to add `umami.track('event-name', {payload})` calls on the marketing site (~1-2h work there) and a new admin surface to define/view these events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reports API — `/api/reports/*`
|
||||||
|
|
||||||
|
**Not currently used.** Umami's "saved reports" system. Endpoints:
|
||||||
|
|
||||||
|
- `GET /reports` + `GET /reports/:id` — list / retrieve saved reports.
|
||||||
|
- `POST /reports/insights` — slice-and-dice with arbitrary filters/dimensions.
|
||||||
|
- `POST /reports/funnel` — multi-step conversion analysis.
|
||||||
|
- `POST /reports/retention` — cohort retention over time.
|
||||||
|
- `POST /reports/utm` — UTM-tagged campaign performance.
|
||||||
|
- `POST /reports/journey` — most common navigation paths.
|
||||||
|
- `POST /reports/goals` — pageview/event-goal completion tracking.
|
||||||
|
- `POST /reports/revenue` — revenue attribution (if we fire `purchase` events with amount).
|
||||||
|
- `POST /reports/attribution` — first/last-click attribution modelling.
|
||||||
|
|
||||||
|
**Best fits for the CRM:**
|
||||||
|
|
||||||
|
- **Funnel report** for the EOI flow: `/berths → /berths/A12 → /inquire?berth=A12 → form submit → CRM EOI signed`. Surface drop-off percentages on the Pulse-style dashboard.
|
||||||
|
- **Journey report** to see "what paths do visitors take before signing an EOI?" — informs marketing-site IA.
|
||||||
|
- **UTM report** to plumb campaign attribution into the lead-source breakdown (currently CRM-side; could be cross-validated against marketing's UTM-tagged traffic).
|
||||||
|
- **Attribution report** to give Pipeline-by-Source a "first-click vs last-click" toggle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Send events from CRM → Umami — `/api/send`
|
||||||
|
|
||||||
|
**Not currently used.** The collect endpoint accepts page hits + custom events from any client. CRM doesn't currently push events, but we could:
|
||||||
|
|
||||||
|
- Fire `umami.track('signed-eoi', {berth: 'A12', deal_value: 50000})` from the CRM after EOI completion — closes the loop between marketing-site funnel and CRM outcome.
|
||||||
|
- Fire `umami.track('contract-signed')`, `umami.track('deposit-received')` — full funnel visible in Umami without leaving it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Multi-website + team admin — `/api/websites`, `/api/teams`, `/api/users`
|
||||||
|
|
||||||
|
**Not currently used.** We hard-code a single `umami_website_id` per port. Useful if a port runs multiple sites (e.g. main marina + residential subdomain): admin UI could list-and-pick from the configured Umami instance's websites instead of requiring manual ID copy-paste. Same for team membership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritized opportunity list
|
||||||
|
|
||||||
|
Ranked by leverage-vs-effort, assuming the v3.1.0 fix in this commit is the baseline:
|
||||||
|
|
||||||
|
1. **Avg session time + bounce rate KPI tiles** (~20 min) — already in the `/stats` response, just need new tiles.
|
||||||
|
2. **`compare=prev` overlay on the pageviews trend chart** (~30 min) — dual-line "vs last period" surface.
|
||||||
|
3. **Country choropleth heatmap** (~4-6h) — already queued in Bucket 3 of the UAT findings doc as "World-map heatmap of Umami visitor origins."
|
||||||
|
4. **Surface top browsers / OS / devices** (~30 min) — additional `TopList` columns; pure UI work.
|
||||||
|
5. **Fire CRM-side events back into Umami** (~2-3h marketing-site + CRM hook) — closes the funnel between marketing and outcomes.
|
||||||
|
6. **EOI funnel via `/api/reports/funnel`** (~3-4h) — drop-off analysis from berth view → inquiry → signed EOI.
|
||||||
|
7. **Identify visitors → link sessions to clients** (~4-6h spread across marketing site + CRM detail surfaces) — biggest unlock but needs marketing-site changes.
|
||||||
|
8. **Sessions-list "recent visitors" panel** (~2-3h) — see who's browsing right now, drill into individual sessions.
|
||||||
|
9. **Saved-reports admin surface** (~6-10h) — let admins create + share Umami reports without leaving the CRM. Bigger product surface; defer until #1-#5 land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service-layer additions needed to support the above
|
||||||
|
|
||||||
|
`src/lib/services/umami.service.ts` currently exports: `getStats`, `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, `testConnection`. To unlock the opportunities above, add:
|
||||||
|
|
||||||
|
- `getSessions(portId, range, opts)` → `/sessions` (paged)
|
||||||
|
- `getSession(portId, sessionId)` → single-session drill-in
|
||||||
|
- `getSessionActivity(portId, sessionId, range)` → event timeline
|
||||||
|
- `getSessionsWeekly(portId, range)` → heatmap source
|
||||||
|
- `getEvents(portId, range)` + `getEventsStats(portId, range)` + `getEventsSeries(portId, range, eventName, unit)` → custom events
|
||||||
|
- `getRealtime(portId, range)` → `/api/realtime/:id` for the live panel
|
||||||
|
- `getReport(portId, reportType, body)` → POST wrappers for funnel/retention/journey/utm/goals/revenue/attribution
|
||||||
|
- `trackEvent(portId, name, payload)` → POST to `/api/send` for CRM → Umami event emission
|
||||||
|
|
||||||
|
Each is a thin wrapper around the existing `umamiFetch` (or a new `umamiPost` variant for the reports endpoints). The auth + JWT cache + retry logic already in place handles them all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gotchas (verified against v3.1.0)
|
||||||
|
|
||||||
|
- Metric `type=url` returns 400 — use `type=path` (handled in our code via back-compat alias).
|
||||||
|
- `/api/websites/:id/pageviews` returns `sessions` only when `compare` is in the query string — keep `.sessions` optional in TS types.
|
||||||
|
- Stats response is **flat** (`pageviews: number`), not nested (`pageviews: {value, prev}`). The v1 nested shape isn't in v2/v3.
|
||||||
|
- `/api/auth/login` returns a JWT with no `expires_in` field — we assume 1h and refresh proactively at 55min.
|
||||||
|
- Visiting `/api` in a browser returns nothing — base path has no GET handler. Use `/api/heartbeat` to check liveness.
|
||||||
|
- Filters are passed as query params (e.g. `&country=DE`), NOT as a JSON `filters` body, per actual API behaviour (docs occasionally show JSON which doesn't work for GET endpoints).
|
||||||
428
docs/website-analytics-flesh-out-plan.md
Normal file
428
docs/website-analytics-flesh-out-plan.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# Website Analytics — flesh-out plan
|
||||||
|
|
||||||
|
**Goal:** rebuild `/{portSlug}/website-analytics` so it feels like a polished native CRM panel that _mirrors_ Umami's idiom rather than reading as a stripped-down embed. Keep a "View in Umami →" deep-link in the header for power users; render most data in-app via the API. Also extend usage into adjacent CRM surfaces (dashboard tiles, inquiry detail, email open-tracking) so Umami stops being "the analytics page" and becomes a cross-cutting data layer.
|
||||||
|
|
||||||
|
**Inputs to this plan:**
|
||||||
|
|
||||||
|
1. Live API capabilities reference — `docs/umami-api-capabilities.md` (verified empirically against v3.1.0 on analytics.portnimara.com).
|
||||||
|
2. Live UI tour via Playwright — screenshots `umami-tour-1-overview.png` through `umami-tour-9-compare.png` (10 surfaces captured).
|
||||||
|
3. Pixel-tracking probe — confirmed the `/p/<slug>` and `/q/<slug>` endpoints + their UI creation forms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Umami's UI actually does — design patterns to mirror
|
||||||
|
|
||||||
|
Tour findings (from 17 sub-pages + 4 team pages):
|
||||||
|
|
||||||
|
| Surface | Visual idiom | Adopt for CRM? |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Overview** | 5-tile KPI row (Visitors / Visits / Views / Bounce rate / Visit duration) — each tile shows headline number + colored arrow chip (green ↑ 58% / red ↓ 39%) + percentage delta. Single stacked bar chart below for traffic time-series (visitors stacked over visits, dual-shade blue). Filter pill + date-range nav top-right. | **Yes** — already mostly there, missing the bounce-rate + visit-duration tiles. |
|
||||||
|
| **Events** | List of custom event names with per-event count + time-series spark. | **Yes** — needs marketing-site event firing first (Phase 4). |
|
||||||
|
| **Sessions** | Dense table: avatar + per-session row showing Visits / Views / Events / Location (flag + city, country) / Browser icon / OS icon / Device icon / Last seen. Tabs for Activity vs Properties (custom session props). | **Yes** — high-leverage; lets reps see _who_ is browsing right now. |
|
||||||
|
| **Realtime** | 4 stat tiles (Views/Visitors/Events/Countries) + auto-refreshing line chart of last 30 min. | **Yes** — already partial via the glance tile. |
|
||||||
|
| **Performance** | Likely page-speed / Core Web Vitals. | Skip — not relevant to marina sales. |
|
||||||
|
| **Compare** | Pick two date ranges side-by-side. | **Partial** — single `compare=prev` overlay on the existing trend chart suffices. |
|
||||||
|
| **Breakdown** | Pivot table view across dimensions. | Skip in v1; expose via Reports later. |
|
||||||
|
| **Goals** | Define event/page-view goals, see completion rate over time. | **Yes** — defer to Phase 5. |
|
||||||
|
| **Funnels** | Multi-step conversion funnel (e.g. /berths → /berths/A12 → /inquire → submit). | **Yes** — Phase 5; high-value for inquiry conversion. |
|
||||||
|
| **Journeys** | Most common navigation paths (Sankey-like). | **Maybe** — defer; nice-to-have. |
|
||||||
|
| **Retention** | Cohort retention grid. | Skip — wrong fit for one-and-done marina inquiry traffic. |
|
||||||
|
| **Replays** | Session replay (likely paid). | Skip — unavailable on our tier. |
|
||||||
|
| **Segments / Cohorts** | Saved filters / user groups. | Skip in v1. |
|
||||||
|
| **UTM** | Campaign attribution by UTM params. | **Yes** — Phase 5 for paid-campaign tracking. |
|
||||||
|
| **Revenue** | Revenue attribution. | Skip — would require firing `purchase` events from CRM after EOI close (consider Phase 6 if leadership wants funnel→revenue). |
|
||||||
|
| **Attribution** | First/last-click attribution model. | **Maybe** — defer. |
|
||||||
|
| **Team-Boards / Websites / Links / Pixels** | Account admin surfaces. | **Pixels + Links: YES — see Phase 4.** Boards/Websites stay in Umami. |
|
||||||
|
|
||||||
|
### Visual specifics worth copying
|
||||||
|
|
||||||
|
- **KPI tile design**: large bold number, label above in muted-grey, arrow + percentage delta below in a colored chip (green-bg for positive, red-bg for negative, fixed-width for alignment). Our `KPITile` already does the right shape — we just need to add the missing two metrics.
|
||||||
|
- **Stacked bar chart for traffic**: dual-shade single bar (visitors as light-blue base, views stacked dark-blue on top). Reads cleaner than two overlapping lines.
|
||||||
|
- **Location rendering**: flag emoji + "City, Country" inline. Use `getCountryName()` + a flag library (twemoji or unicode regional indicators).
|
||||||
|
- **Browser/OS/Device icons**: small colored brand glyphs inline. Use `simple-icons` or `lucide` equivalents.
|
||||||
|
- **Filter chip + date nav**: `<` `>` arrows step through the date range; dropdown opens to preset list. Adopt the same pattern on our shell — currently we only have presets, no step-arrows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phased build plan
|
||||||
|
|
||||||
|
### Phase 1 — Fill out the Overview tiles & chart (~3-4h)
|
||||||
|
|
||||||
|
Quick wins that close visual parity with Umami's Overview:
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||||
|
| Add **Bounce rate** KPI tile | `website-analytics-shell.tsx` | derive `bounces / visits * 100`; service field already there |
|
||||||
|
| Add **Avg visit duration** KPI tile | `website-analytics-shell.tsx` | derive `totaltime / visits` formatted as `Xm Ys`; service field already there |
|
||||||
|
| Add **`<` `>` date-step arrows** on the date-range chip | `date-range-picker.tsx` | step the current preset by one window (today→yesterday, 7d→prior-7d, etc.) |
|
||||||
|
| Convert pageviews trend to **stacked bar** (visitors vs views) | `pageviews-chart.tsx` | recharts `BarChart` stacked, light/dark blue |
|
||||||
|
| Add **`compare=prev` overlay toggle** on the trend chart | `pageviews-chart.tsx` + service `getPageviewsSeries` | optional "vs prior period" series rendered as dashed line |
|
||||||
|
| Add **Top browsers / OS / devices** ranked-list cards | new `<TopList>` consumers; service already exposes via `getMetric(type)` | mirror Top Pages/Referrers/Countries layout |
|
||||||
|
| **World choropleth heatmap** card (already queued separately) | new `visitor-world-map.tsx` (Natural Earth topojson + react-simple-maps) | ~4-6h on its own |
|
||||||
|
|
||||||
|
**Cumulative result:** Overview surface reads at ~80% parity with Umami's Overview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Sessions surface (~4-5h)
|
||||||
|
|
||||||
|
New `/website-analytics/sessions` tab + supporting service wrappers:
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------ | ------------------ | ------- |
|
||||||
|
| Service: `getSessions(portId, range, opts)` → `/api/websites/:id/sessions` (paged) | `umami.service.ts` | ~30 min |
|
||||||
|
| Service: `getSession(portId, sessionId)` → single-session detail | `umami.service.ts` | ~15 min |
|
||||||
|
| Service: `getSessionActivity(portId, sessionId, range)` → event timeline | `umami.service.ts` | ~15 min |
|
||||||
|
| Service: `getSessionsWeekly(portId, range)` → hour-of-week heatmap | `umami.service.ts` | ~15 min |
|
||||||
|
| API route: `/api/v1/website-analytics?metric=sessions[&sessionId=...]` | route.ts | ~30 min |
|
||||||
|
| UI: `sessions-table.tsx` — dense rows mirroring Umami (avatar + location flag + browser/OS/device icons + Last seen) | new component | ~2h |
|
||||||
|
| UI: `session-detail-sheet.tsx` — right-side Sheet drawer showing the session's full event timeline when a row is clicked | new component | ~1h |
|
||||||
|
| UI: `weekly-heatmap-card.tsx` — 7×24 grid colour-scaled by session count, hover for tooltip | new component | ~1h |
|
||||||
|
|
||||||
|
**Unlock:** rep can see "who is currently browsing right now, where from, on what device, what they're looking at" — directly actionable for sales follow-up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Events surface (~3-4h, BLOCKED on Phase 4a)
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| -------------------------------------------------------------------------------------------- | ------------------ | ------- |
|
||||||
|
| Service: `getEvents(portId, range, opts)` → `/events` paged list | `umami.service.ts` | ~30 min |
|
||||||
|
| Service: `getEventsStats(portId, range)` → totals | `umami.service.ts` | ~15 min |
|
||||||
|
| Service: `getEventsSeries(portId, range, eventName, unit)` → per-event time-series | `umami.service.ts` | ~15 min |
|
||||||
|
| API route addition | route.ts | ~30 min |
|
||||||
|
| UI: `events-tab.tsx` — list of event names with per-event count + spark + drill-in | new component | ~1.5h |
|
||||||
|
| UI: `event-detail-sheet.tsx` — single event's time-series chart + filter by payload property | new component | ~1h |
|
||||||
|
|
||||||
|
**Dependency:** the marketing site must fire `umami.track(name, payload)` calls (Phase 4a). Without this, Events tab is empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — Pixel tracking + link tracking + marketing-site event push
|
||||||
|
|
||||||
|
**Phase 4a — Marketing-site event tracking (~2-3h on marketing repo)**
|
||||||
|
|
||||||
|
Add `umami.track()` calls in the marketing site:
|
||||||
|
|
||||||
|
- `inquiry-submitted` with `{berth, source}` payload — fires on EOI form submit
|
||||||
|
- `brochure-download` with `{brochureId}` — fires on brochure download
|
||||||
|
- `berth-detail-viewed` with `{berthId, mooring}` — fires on `/berths/[mooring]` page view
|
||||||
|
- `phone-revealed` / `email-revealed` — fires when contact details are exposed
|
||||||
|
|
||||||
|
These light up the Events tab + enable funnel analysis in Phase 5.
|
||||||
|
|
||||||
|
**Phase 4b — Pixel-based email open tracking (~3-4h CRM-side)**
|
||||||
|
|
||||||
|
Probe finding: Umami exposes pixel URLs at `https://analytics.portnimara.com/p/<slug>` — fetching the URL records an event. Use case: embed in HTML emails as a 1x1 image.
|
||||||
|
|
||||||
|
**Two architecture options:**
|
||||||
|
|
||||||
|
**Option A — One Umami pixel per email type** (simple, low fidelity)
|
||||||
|
|
||||||
|
- Create a pixel manually in Umami for each templated email type (`portal-invite`, `eoi-sent`, `reservation-reminder`, etc.)
|
||||||
|
- Embed the static pixel URL in each template
|
||||||
|
- Pro: zero CRM-side code beyond template HTML. Open rates roll up in Umami by pixel.
|
||||||
|
- Con: can't tell _which recipient_ opened — only aggregate counts per template.
|
||||||
|
|
||||||
|
**Option B — One Umami pixel + CRM-side per-send tracking endpoint** (richer, recommended)
|
||||||
|
|
||||||
|
- Build `GET /api/public/email-pixel/:sendId.gif` in our CRM that:
|
||||||
|
1. Returns a 1×1 transparent GIF
|
||||||
|
2. Records the open in `document_sends.opened_at` (already a table; per CLAUDE.md "send-from accounts" section)
|
||||||
|
3. Optionally proxies the hit to Umami via `POST /api/send` with the email type + send id as event properties for cross-correlation
|
||||||
|
- Embed `<img src="https://crm.portnimara.com/api/public/email-pixel/{sendId}.gif" width="1" height="1" />` in every templated email
|
||||||
|
- Pro: per-recipient open tracking + open-time + CRM-attached. Funnels by email type via Umami too.
|
||||||
|
- Con: needs the public endpoint + a schema column (or reuse `document_sends.opened_at`).
|
||||||
|
|
||||||
|
**Recommendation: ship Option B.** The CRM-side hook gives us per-deal attribution ("client X opened the EOI reminder twice but hasn't signed"), and Umami still gets the aggregate.
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------- |
|
||||||
|
| New endpoint `/api/public/email-pixel/[sendId]/route.ts` returning a 1×1 GIF + recording open | new route | ~1h |
|
||||||
|
| Migration: add `opened_at`, `open_count`, `last_opened_user_agent` to `document_sends` if not present | drizzle migration | ~30 min |
|
||||||
|
| Email template helper: inject the pixel HTML into every transactional template | `src/lib/email/render.ts` | ~30 min |
|
||||||
|
| UI surface: on each `document_sends` row in the activity feed, show "Opened N times, last at X" badge | `email-activity-row.tsx` | ~1h |
|
||||||
|
| Cross-post to Umami via `trackEvent('email-opened', {emailType, sendId})` so Umami funnel data includes opens | new `trackEvent` wrapper in `umami.service.ts` | ~30 min |
|
||||||
|
| Privacy: respect `EMAIL_REDIRECT_TO` dev gate; don't fire pixels for redirected dev emails | ditto | ~15 min |
|
||||||
|
|
||||||
|
**Phase 4c — Tracked redirect links (~1.5h)**
|
||||||
|
|
||||||
|
Umami's `/q/<slug>` endpoint is a tracked redirect — records a click then 302s to the destination URL. Use for outbound CTAs:
|
||||||
|
|
||||||
|
- "View brochure" links in emails → wrap via Umami link → records click → opens brochure
|
||||||
|
- "Schedule a viewing" buttons → wrap via Umami link → click attribution
|
||||||
|
- Marketing-site CTAs → wrap → measure engagement
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------- |
|
||||||
|
| Service: `createTrackedLink(name, destinationUrl)` → POST to Umami's links endpoint via authenticated API | `umami.service.ts` | ~45 min |
|
||||||
|
| Email template helper: `<trackedLink href="..." name="...">` JSX wrapper that auto-creates the Umami link on first render + caches the slug | `src/lib/email/components/` | ~45 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Reports surfaces (Funnels, UTM, Journeys) (~6-8h)
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------ |
|
||||||
|
| Service: `getReport(reportType, body)` POST wrapper covering `/funnel`, `/journey`, `/utm`, `/goals`, `/retention`, `/revenue`, `/attribution` | `umami.service.ts` | ~1h |
|
||||||
|
| UI: `/website-analytics/funnels` page — admin-configurable funnel definitions (steps as event names or URL paths), per-step drop-off chart | new page | ~3h |
|
||||||
|
| UI: `/website-analytics/utm` page — UTM source/medium/campaign breakdown with click-through to attributed sessions | new page | ~2h |
|
||||||
|
| UI: `/website-analytics/journeys` page — top navigation paths rendered as ranked list (skip Sankey for v1) | new page | ~1.5h |
|
||||||
|
| Defer: Goals / Retention / Revenue / Attribution to v2 (low signal for marina sales) | | |
|
||||||
|
|
||||||
|
**High-leverage funnels to wire as defaults:**
|
||||||
|
|
||||||
|
- **Inquiry funnel**: `/` → `/berths` → `/berths/[mooring]` → `inquiry-submitted` event → CRM `eoi-signed` (cross-system!) → CRM `reservation-paid` (cross-system!)
|
||||||
|
- **Email funnel**: `email-sent` → `email-opened` (pixel) → tracked-link click → CRM action
|
||||||
|
|
||||||
|
The cross-system funnels require Phase 4 to be live first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6 — CRM → Umami event push for outcome attribution (~2-3h)
|
||||||
|
|
||||||
|
Close the funnel from "marketing site click" → "CRM closed deal" by firing CRM-side events back into Umami via `POST /api/send`:
|
||||||
|
|
||||||
|
| Event | Fired by | Payload |
|
||||||
|
| ---------------------- | -------------------------------------------- | --------------------------------------- |
|
||||||
|
| `crm-inquiry-created` | `createInterest()` in `interests.service.ts` | `{interestId, source, leadCategory}` |
|
||||||
|
| `crm-eoi-sent` | `generateAndSign()` after EOI dispatch | `{interestId, berth, pathway}` |
|
||||||
|
| `crm-eoi-signed` | Documenso `DOCUMENT_COMPLETED` webhook | `{interestId, berth}` |
|
||||||
|
| `crm-reservation-paid` | manual stage advance to `deposit_paid` | `{interestId, berth, amount, currency}` |
|
||||||
|
| `crm-contract-signed` | manual stage advance to `contract` | `{interestId, berth, amount, currency}` |
|
||||||
|
|
||||||
|
| Task | File | Effort |
|
||||||
|
| ----------------------------------------------------------------------------------------- | ------------------- | -------- |
|
||||||
|
| Service: `trackEvent(name, payload, sessionId?)` → `POST /api/send` on the Umami instance | `umami.service.ts` | ~45 min |
|
||||||
|
| Hook into the 5 service entry points above (one event per outcome milestone) | each service file | ~1.5h |
|
||||||
|
| Audit log entry per event sent so we can verify Umami received it | `audit_logs` insert | included |
|
||||||
|
|
||||||
|
**Unlock:** Umami's Revenue + Attribution reports start showing CRM outcomes attributed to marketing-site channels — closes the leadership question "which traffic sources actually generate signed deals, not just leads?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7 — Cross-cutting CRM placements (~3-4h)
|
||||||
|
|
||||||
|
Beyond the dedicated `/website-analytics` page, surface Umami data inside CRM context:
|
||||||
|
|
||||||
|
| Placement | What | Effort |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------ |
|
||||||
|
| **Dashboard rail tile** (already shipped) — Pageviews + active now | already done in this session | — |
|
||||||
|
| **Inquiry detail page** — "Source attribution" card showing the inquiry's UTM params, landing page, time-on-site, pages-viewed-before-submit. Pulls from `getSession(sessionId)` if the inquiry's create payload includes a session ID (requires marketing-site change to pass it). | new `inquiry-attribution-card.tsx` | ~1.5h + marketing-site change |
|
||||||
|
| **Client detail page** — "Website activity" card: total sessions, pageviews, last-seen, top pages visited. Requires `umami.identify({email})` on marketing site to link sessions back to clients. | new `client-web-activity-card.tsx` | ~1.5h + marketing-site identify call |
|
||||||
|
| **Berth detail page** — "Marketing demand" card: pageviews to `/berths/{mooring}` over time + referrer breakdown. Drives "this berth is being viewed but not inquired-about — flag for outreach." | new `berth-demand-card.tsx` | ~1h |
|
||||||
|
| **Document send activity** — pixel opens per recipient (from Phase 4b) | inline on existing `document_sends` rows | included in 4b |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. Library adoptions (changes the plan materially)
|
||||||
|
|
||||||
|
Context7 lookup surfaced three official libraries that reshape the plan. **Adopt all three.**
|
||||||
|
|
||||||
|
### `@umami/api-client` — official read-side client
|
||||||
|
|
||||||
|
Covers every read endpoint we need including all the report types. Built-in filter support, login/JWT auth handled internally, `{ok, data}` discriminated union for clean error handling.
|
||||||
|
|
||||||
|
**Replaces:** ~60-70% of our current `umami.service.ts` (drop `umamiFetch`, JWT cache, decrypt boilerplate; keep thin wrappers with existing signatures so consumers don't change).
|
||||||
|
|
||||||
|
**One-time refactor (~2h):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const clientByPort = new Map<string, UmamiApiClient>();
|
||||||
|
|
||||||
|
async function getClient(portId: string): Promise<UmamiApiClient | null> {
|
||||||
|
if (clientByPort.has(portId)) return clientByPort.get(portId)!;
|
||||||
|
const cfg = await loadUmamiConfig(portId);
|
||||||
|
if (!cfg) return null;
|
||||||
|
const client = new UmamiApiClient({
|
||||||
|
apiEndpoint: `${cfg.apiUrl}/api`,
|
||||||
|
apiKey: cfg.apiToken ?? undefined,
|
||||||
|
});
|
||||||
|
if (!cfg.apiToken && cfg.username && cfg.password) await client.login(cfg.username, cfg.password);
|
||||||
|
clientByPort.set(portId, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(portId: string, range: DateRange) {
|
||||||
|
const client = await getClient(portId);
|
||||||
|
if (!client) return null;
|
||||||
|
const { from, to } = rangeToBounds(range);
|
||||||
|
const result = await client.getWebsiteStats(WEBSITE_ID, {
|
||||||
|
startAt: from.getTime(),
|
||||||
|
endAt: to.getTime(),
|
||||||
|
});
|
||||||
|
return result.ok ? result.data : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for `getPageviewsSeries`, `getMetric`, `getActiveVisitors`, plus new ones from the SDK: `getRealtime`, `getWebsiteSessionStats`, `runFunnelReport`, `runJourneyReport`, etc.
|
||||||
|
|
||||||
|
### `@umami/node` — official write-side SDK
|
||||||
|
|
||||||
|
For Phase 6 (CRM → Umami push) and Phase 4b cross-post:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const umami = new Umami({ websiteId, hostUrl });
|
||||||
|
await umami.track({
|
||||||
|
url: '/crm/eoi-signed',
|
||||||
|
name: 'crm-eoi-signed',
|
||||||
|
data: { interestId, berth, dealValue },
|
||||||
|
});
|
||||||
|
await umami.identify({ sessionId, email, interestId });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replaces:** the planned hand-rolled `trackEvent()` wrapper. Single line per outcome milestone.
|
||||||
|
|
||||||
|
### `react-simple-maps` — for the world heatmap (Phase 1b)
|
||||||
|
|
||||||
|
Declarative SVG choropleth on d3-geo + topojson-client. SSR-safe. Use `topojson/world-atlas` (110m resolution ~30KB) cached in `public/`. Bundle ~30-50KB + topojson 30-100KB.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<ComposableMap projection="geoMercator">
|
||||||
|
<Geographies geography="/world-110m.json">
|
||||||
|
{({ geographies }) =>
|
||||||
|
geographies.map((geo) => (
|
||||||
|
<Geography
|
||||||
|
key={geo.rsmKey}
|
||||||
|
geography={geo}
|
||||||
|
fill={scaleByVisitorCount(visitorsByCountry[geo.properties.iso_a2] ?? 0)}
|
||||||
|
onClick={() => onCountryClick(geo.properties.iso_a2)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Geographies>
|
||||||
|
</ComposableMap>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chose this over visx/Nivo/Chart.js Geo:** visx is overkill for one map; Nivo + Chart.js force a different charting idiom (we use recharts everywhere); react-simple-maps' compose-primitives shape matches our recharts pattern.
|
||||||
|
|
||||||
|
### Net effect on phase efforts
|
||||||
|
|
||||||
|
| Phase | Original estimate | Revised after library adoption |
|
||||||
|
| ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
|
||||||
|
| Service refactor (one-time) | — | **+2h** (one-time foundation; pays back across all phases) |
|
||||||
|
| Phase 1 — Overview parity | 3-4h | 3-4h (unchanged; api-client makes the filter additions trivial) |
|
||||||
|
| Phase 1b — World heatmap | 4-6h | 3-4h (library choice locked in) |
|
||||||
|
| Phase 2 — Sessions | 4-5h | 3-4h (api-client has session methods built-in) |
|
||||||
|
| Phase 3 — Events | 3-4h | 2-3h (api-client provides) |
|
||||||
|
| Phase 4b — Pixel hybrid | 3-4h | 2.5-3h (cross-post is one line) |
|
||||||
|
| Phase 5 — Reports (funnels/UTM/journeys) | 6-8h | 3-4h (every report method pre-wrapped) |
|
||||||
|
| Phase 6 — CRM → Umami push | 2-3h | 1.5h (`@umami/node` handles transport) |
|
||||||
|
|
||||||
|
**Total scope drops from ~30-40h to ~22-28h** with these adoptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Service-layer additions consolidated
|
||||||
|
|
||||||
|
Add to `src/lib/services/umami.service.ts` (each is a thin wrapper around existing `umamiFetch` / new `umamiPost`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Sessions (Phase 2)
|
||||||
|
getSessions(portId, range, { page?, pageSize?, query? }) → /sessions
|
||||||
|
getSession(portId, sessionId) → /sessions/:id
|
||||||
|
getSessionActivity(portId, sessionId, range) → /sessions/:id/activity
|
||||||
|
getSessionProperties(portId, sessionId) → /sessions/:id/properties
|
||||||
|
getSessionsWeekly(portId, range, timezone) → /sessions/weekly
|
||||||
|
|
||||||
|
// Events (Phase 3)
|
||||||
|
getEvents(portId, range, opts) → /events
|
||||||
|
getEventsStats(portId, range) → /events/stats
|
||||||
|
getEventsSeries(portId, range, eventName, unit) → /events/series
|
||||||
|
getEventDataProperties(portId, range) → /event-data/properties
|
||||||
|
|
||||||
|
// Realtime (Phase 1)
|
||||||
|
getRealtime(portId, range) → /api/realtime/:id (richer than /active)
|
||||||
|
|
||||||
|
// Reports (Phase 5)
|
||||||
|
getReport(portId, reportType, body) → POST /api/reports/:type (funnel/journey/utm/goals/retention/revenue/attribution)
|
||||||
|
|
||||||
|
// CRM → Umami (Phase 6)
|
||||||
|
trackEvent(portId, name, payload, sessionId?) → POST /api/send
|
||||||
|
|
||||||
|
// Links + Pixels admin (Phase 4)
|
||||||
|
createTrackedLink(portId, name, destinationUrl) → POST team-level /links
|
||||||
|
createTrackingPixel(portId, name) → POST team-level /pixels
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a new `umamiPost(config, path, body)` helper alongside the existing `umamiFetch` since GET-only doesn't cover reports + send.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Pixel-tracking answer (the user's specific question)
|
||||||
|
|
||||||
|
**Q: Can we use Umami's pixel tracking for email opens?**
|
||||||
|
|
||||||
|
**A: Yes — and recommended in hybrid form.** Direct verification on the live instance:
|
||||||
|
|
||||||
|
- Pixel UI at `/teams/[teamId]/pixels` lets an admin create named pixels. Each gets an auto-generated slug.
|
||||||
|
- The pixel URL is `https://analytics.portnimara.com/p/<slug>` — fetching it records an event (no auth required from the email client side; the slug is the credential).
|
||||||
|
- Embedded as `<img src="..." width="1" height="1" />` in HTML emails, it fires when the email is rendered (Outlook/Apple Mail/etc.).
|
||||||
|
- Standard caveats: Apple Mail privacy protection pre-fetches images server-side → opens may be over-counted for iOS users. Some recipients block images entirely → opens under-counted. Same caveats as every email tracking pixel ever.
|
||||||
|
|
||||||
|
**Recommended hybrid (Phase 4b above):** build a CRM-side pixel endpoint `/api/public/email-pixel/[sendId].gif` that:
|
||||||
|
|
||||||
|
- Returns the 1×1 GIF
|
||||||
|
- Records `opened_at` in `document_sends`
|
||||||
|
- Cross-posts the hit to Umami via `POST /api/send` so the Umami Events tab + funnels include opens
|
||||||
|
|
||||||
|
This way: per-recipient attribution in the CRM, aggregate roll-ups in Umami, single source of truth for both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Effort summary + prioritization
|
||||||
|
|
||||||
|
| Phase | Scope | Effort | Priority |
|
||||||
|
| ----- | ------------------------------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------ |
|
||||||
|
| 1 | Overview parity (KPI tiles, stacked-bar chart, date arrows, browser/OS/device cards) | ~3-4h | **High** — most visible polish, no dependencies |
|
||||||
|
| 1b | World choropleth heatmap (already queued separately) | ~4-6h | **High** if leadership wants the visual |
|
||||||
|
| 2 | Sessions surface (table + detail sheet + weekly heatmap) | ~4-5h | **High** — biggest "wow" + actionable |
|
||||||
|
| 3 | Events surface | ~3-4h | **Medium** — blocked on 4a |
|
||||||
|
| 4a | Marketing-site event tracking | ~2-3h (marketing repo) | **High** — unblocks 3 + 5 |
|
||||||
|
| 4b | Pixel-based email open tracking (hybrid) | ~3-4h | **High** — direct ask + immediate value |
|
||||||
|
| 4c | Tracked redirect links | ~1.5h | **Medium** |
|
||||||
|
| 5 | Reports (Funnels, UTM, Journeys) | ~6-8h | **Medium** — depends on 4a being live |
|
||||||
|
| 6 | CRM → Umami event push for outcome attribution | ~2-3h | **Medium-high** — needed to close marketing→outcome loop |
|
||||||
|
| 7 | Cross-cutting placements (inquiry / client / berth detail cards) | ~3-4h | **Medium** — depends on `umami.identify()` on marketing site |
|
||||||
|
|
||||||
|
**Recommended build order (updated 2026-05-19 per user):**
|
||||||
|
|
||||||
|
1. ✅ **Service refactor** — Kept hand-rolled `umamiFetch` (the official `@umami/api-client` transitively pulls `next-basics` which requires React at module-import time, breaking SSR + tsx scripts). Adopted `@umami/node` for the write side.
|
||||||
|
2. ✅ **Phase 1** — Overview parity (KPI tiles + browser/OS/device cards + date arrows + stacked-bar chart + `compare=prev` overlay)
|
||||||
|
3. ✅ **Phase 1b** — World heatmap. **Switched from `react-simple-maps` to ECharts + `public/world-map/echarts-world.json`** — the `world-atlas/110m` topojson has antimeridian-crossing polygons (Russia/Fiji/Antarctica) that render a horizontal line through the equator regardless of projection. ECharts' world.json is pre-cleaned.
|
||||||
|
4. ✅ **Phase 4b** — Pixel-based email open tracking. `document_send_opens` table + `/api/public/email-pixel/[sendId]` endpoint + `injectTrackingPixel` helper wired into `performSend`. Per-port kill switch `email_open_tracking_enabled` (admin UI on `/admin/website-analytics`). Cross-posts to Umami as `email-opened`.
|
||||||
|
5. ✅ **Phase 2** — Sessions surface. `SessionsList` (paginated, click-through to detail), `SessionDetailSheet` (full activity stream), `WeeklyHeatmap` (7×24 grid). API endpoints `sessions`, `session`, `session-activity`, `sessions-weekly`.
|
||||||
|
6. ✅ **Phase 6** — CRM → Umami event push. `trackEvent` calls wired into `createInterest` (`interest-created`), `updateInterestStage` (`interest-stage-changed`), `setInterestOutcome` (`interest-outcome-set`).
|
||||||
|
7. ✅ **Phase 7** — Cross-cutting placements. `email-sent` (in `performSend`), `eoi-signed` (in `handleDocumentCompleted`). Remaining placements (inquiry / berth detail attribution cards) defer until UI surfaces them.
|
||||||
|
8. ✅ **Phase 4c** — Tracked redirect links. `tracked_links` + `tracked_link_clicks` tables + `/q/[slug]` redirect endpoint + `createTrackedLink` / `buildTrackedUrl` service helpers. Email-composer integration deferred to UI follow-up.
|
||||||
|
9. **Phase 3 + Phase 5 — DEFERRED to the end.** Events tab is empty until marketing-site `umami.track()` calls land (Phase 4a, deferred). Funnels save for the end per user direction — pageview-only marketing funnel is the v1; richer event-based funnels come later.
|
||||||
|
10. **Phase 4a + cross-system funnels** — when there's appetite for marketing-site repo changes, unlock Events tab + cross-system funnels.
|
||||||
|
|
||||||
|
**Total scope: ~22-28h** with library adoptions, of which ~13-15h is the high-priority Phases 1 + 1b + 4b + 2 + 6 that ship first.
|
||||||
|
|
||||||
|
Total scope: ~30-40h end-to-end for the full flesh-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What stays in Umami vs. mirrored in CRM
|
||||||
|
|
||||||
|
| In CRM (mirror) | In Umami only (deep-link) |
|
||||||
|
| ----------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| Overview / KPI tiles / trend chart | Replays (paid) |
|
||||||
|
| Sessions list + detail | Retention (low signal) |
|
||||||
|
| Top pages / referrers / countries / browsers / OS / devices | Saved Boards (admin power-user) |
|
||||||
|
| Events + per-event drill | Pixels/Links admin CRUD (use Umami for setup; render data in CRM) |
|
||||||
|
| Funnels + UTM + Journeys | Performance / Web Vitals |
|
||||||
|
| World heatmap | Cohorts / Segments (defer until use case emerges) |
|
||||||
|
| Email open tracking | Multi-website CRUD |
|
||||||
|
|
||||||
|
Every page header in the CRM analytics surface gets a small "View in Umami →" outbound link in the corner for power users who want the full feature surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions for the user before implementation
|
||||||
|
|
||||||
|
1. **Marketing site repo access**: Phase 4a (umami.track calls), Phase 4 (umami.identify for client linkage), and Phase 7 (passing sessionId to inquiry intake) all require changes there. Confirm whoever owns the marketing site is in the loop.
|
||||||
|
2. **Pixel hybrid vs Umami-only**: do you want per-recipient open tracking (hybrid) or just aggregate (Umami-only)? Recommended hybrid above; switch to Umami-only if the engineering cost isn't worth it.
|
||||||
|
3. **Funnel definitions**: who defines the canonical funnels? Suggest admins set them up via a CRM-side admin page that POSTs to Umami's `/api/reports/funnel`, with the most important funnels (inquiry, email-conversion) seeded as defaults at install time.
|
||||||
|
4. **Privacy / GDPR**: email pixel tracking + `umami.identify({email})` linkage both touch PII. Confirm consent model — likely already handled by the marketing-site cookie banner, but the email pixel needs explicit opt-out handling (e.g. don't fire pixel if the recipient is in a do-not-track list).
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@umami/node": "^0.4.0",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.6.11",
|
"better-auth": "^1.6.11",
|
||||||
@@ -78,6 +79,8 @@
|
|||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"imapflow": "^1.3.3",
|
"imapflow": "^1.3.3",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
@@ -141,6 +144,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/iso-3166-2": "^1.0.4",
|
"@types/iso-3166-2": "^1.0.4",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.0",
|
||||||
@@ -148,6 +152,7 @@
|
|||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
78
pnpm-lock.yaml
generated
78
pnpm-lock.yaml
generated
@@ -118,6 +118,9 @@ importers:
|
|||||||
'@types/pdfkit':
|
'@types/pdfkit':
|
||||||
specifier: ^0.17.6
|
specifier: ^0.17.6
|
||||||
version: 0.17.6
|
version: 0.17.6
|
||||||
|
'@umami/node':
|
||||||
|
specifier: ^0.4.0
|
||||||
|
version: 0.4.0
|
||||||
'@use-gesture/react':
|
'@use-gesture/react':
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1(react@19.2.6)
|
version: 10.3.1(react@19.2.6)
|
||||||
@@ -151,6 +154,12 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.2
|
specifier: ^0.45.2
|
||||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
|
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
|
echarts-for-react:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6(echarts@6.0.0)(react@19.2.6)
|
||||||
embla-carousel-react:
|
embla-carousel-react:
|
||||||
specifier: ^8.6.0
|
specifier: ^8.6.0
|
||||||
version: 8.6.0(react@19.2.6)
|
version: 8.6.0(react@19.2.6)
|
||||||
@@ -335,6 +344,9 @@ importers:
|
|||||||
'@types/archiver':
|
'@types/archiver':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
'@types/geojson':
|
||||||
|
specifier: ^7946.0.16
|
||||||
|
version: 7946.0.16
|
||||||
'@types/iso-3166-2':
|
'@types/iso-3166-2':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4
|
version: 1.0.4
|
||||||
@@ -356,6 +368,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@types/topojson-client':
|
||||||
|
specifier: ^3.1.5
|
||||||
|
version: 3.1.5
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
|
version: 6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
|
||||||
@@ -3240,6 +3255,9 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/iso-3166-2@1.0.4':
|
'@types/iso-3166-2@1.0.4':
|
||||||
resolution: {integrity: sha512-tXaeT4FDobC8rAy6LoFvbGA4vhOQQNIdSRC5DAoYfT3D9ohnKHkDFxHzSln6WqTKVeKLrnMiMQubM8m3fqNp/w==}
|
resolution: {integrity: sha512-tXaeT4FDobC8rAy6LoFvbGA4vhOQQNIdSRC5DAoYfT3D9ohnKHkDFxHzSln6WqTKVeKLrnMiMQubM8m3fqNp/w==}
|
||||||
|
|
||||||
@@ -3293,6 +3311,12 @@ packages:
|
|||||||
'@types/tedious@4.0.14':
|
'@types/tedious@4.0.14':
|
||||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -3367,6 +3391,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
|
resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@umami/node@0.4.0':
|
||||||
|
resolution: {integrity: sha512-pyphprbiF7KiDSc+SWZ4/rVM8B5vU27zIiFfEPj2lEqczpI4xAKSp+dM3tlzyRAWJL32fcbCfAaLGhJZQV13Rg==}
|
||||||
|
|
||||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
@@ -4467,6 +4494,15 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
echarts-for-react@3.0.6:
|
||||||
|
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
||||||
|
peerDependencies:
|
||||||
|
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||||
|
react: ^15.0.0 || >=16.0.0
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.353:
|
electron-to-chromium@1.5.353:
|
||||||
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
|
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
|
||||||
|
|
||||||
@@ -6572,6 +6608,9 @@ packages:
|
|||||||
sisteransi@1.0.5:
|
sisteransi@1.0.5:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
|
size-sensor@1.0.3:
|
||||||
|
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
|
||||||
|
|
||||||
slice-ansi@7.1.2:
|
slice-ansi@7.1.2:
|
||||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -6954,6 +6993,9 @@ packages:
|
|||||||
tslib@1.14.1:
|
tslib@1.14.1:
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -7431,6 +7473,9 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
zustand@5.0.13:
|
zustand@5.0.13:
|
||||||
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
|
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -10079,6 +10124,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/iso-3166-2@1.0.4': {}
|
'@types/iso-3166-2@1.0.4': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -10140,6 +10187,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 20.19.41
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
'@types/topojson-specification': 1.0.5
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -10248,6 +10304,8 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.59.3
|
'@typescript-eslint/types': 8.59.3
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
|
'@umami/node@0.4.0': {}
|
||||||
|
|
||||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -11225,6 +11283,18 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
echarts: 6.0.0
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
react: 19.2.6
|
||||||
|
size-sensor: 1.0.3
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.353: {}
|
electron-to-chromium@1.5.353: {}
|
||||||
|
|
||||||
embla-carousel-react@8.6.0(react@19.2.6):
|
embla-carousel-react@8.6.0(react@19.2.6):
|
||||||
@@ -13604,6 +13674,8 @@ snapshots:
|
|||||||
|
|
||||||
sisteransi@1.0.5: {}
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
|
size-sensor@1.0.3: {}
|
||||||
|
|
||||||
slice-ansi@7.1.2:
|
slice-ansi@7.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
@@ -14006,6 +14078,8 @@ snapshots:
|
|||||||
|
|
||||||
tslib@1.14.1: {}
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsx@4.21.0:
|
tsx@4.21.0:
|
||||||
@@ -14511,6 +14585,10 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|
||||||
zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)):
|
zustand@5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|||||||
32100
public/world-map/echarts-world.json
Normal file
32100
public/world-map/echarts-world.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,33 +6,27 @@ import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-port Umami credentials. We deliberately keep all three values
|
* Per-port Umami credentials. Self-hosted Umami uses username + password →
|
||||||
* port-scoped (per the operator decision) so different ports can point at
|
* JWT bearer token (https://docs.umami.is/docs/api/authentication); the
|
||||||
* different Umami instances if needed. The /website-analytics dashboard
|
* service POSTs to /api/auth/login and caches the JWT in-memory. Umami
|
||||||
* page reads these settings via the umami.service layer at request time.
|
* Cloud installations use a long-lived API key instead; the optional field
|
||||||
|
* below covers that case. All credentials are port-scoped so different
|
||||||
|
* ports can point at different Umami instances.
|
||||||
*/
|
*/
|
||||||
const FIELDS: SettingFieldDef[] = [
|
const FIELDS: SettingFieldDef[] = [
|
||||||
{
|
{
|
||||||
key: 'umami_api_url',
|
key: 'umami_api_url',
|
||||||
label: 'Umami API URL',
|
label: 'Umami URL',
|
||||||
description:
|
description:
|
||||||
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
|
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: 'https://analytics.portnimara.com',
|
placeholder: 'https://analytics.portnimara.com',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'umami_api_token',
|
|
||||||
label: 'API token',
|
|
||||||
description:
|
|
||||||
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
|
|
||||||
type: 'password',
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'umami_username',
|
key: 'umami_username',
|
||||||
label: 'Username',
|
label: 'Username',
|
||||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
description: 'Umami login username (self-hosted).',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: 'admin',
|
placeholder: 'admin',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
@@ -40,7 +34,8 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
{
|
{
|
||||||
key: 'umami_password',
|
key: 'umami_password',
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
description:
|
||||||
|
'Umami login password (self-hosted). Exchanged for a JWT via /api/auth/login on each port; the JWT is cached for 55 minutes. Stored AES-256-GCM at rest.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
@@ -53,6 +48,28 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'umami_api_token',
|
||||||
|
label: 'API key (Umami Cloud only — optional)',
|
||||||
|
description:
|
||||||
|
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||||
|
type: 'password',
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
||||||
|
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||||
|
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||||
|
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||||
|
{
|
||||||
|
key: 'email_open_tracking_enabled',
|
||||||
|
label: 'Track email opens',
|
||||||
|
description:
|
||||||
|
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function WebsiteAnalyticsSettingsPage() {
|
export default function WebsiteAnalyticsSettingsPage() {
|
||||||
@@ -65,10 +82,16 @@ export default function WebsiteAnalyticsSettingsPage() {
|
|||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Umami connection"
|
title="Umami connection"
|
||||||
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
|
description="Self-hosted Umami: enter URL + username + password + website ID. Umami Cloud: enter URL + API key (Cloud field at the bottom) + website ID. Each port can point at its own Umami instance, or share one instance with different website IDs."
|
||||||
fields={FIELDS}
|
fields={FIELDS}
|
||||||
extra={<UmamiTestButton />}
|
extra={<UmamiTestButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsFormCard
|
||||||
|
title="Email open tracking"
|
||||||
|
description="Opt-in tracking for outbound sales emails. Disabled by default."
|
||||||
|
fields={TRACKING_FIELDS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { MetricDetailShell } from '@/components/website-analytics/metric-detail-shell';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full ranked-list view for one analytics metric (pages / referrers /
|
||||||
|
* countries / browsers / os / devices). Reached via the "View all" link
|
||||||
|
* on each top-N card. Honours the `range` (and optional `from`/`to`)
|
||||||
|
* query params so the detail page mirrors the time window the operator
|
||||||
|
* had selected on the parent page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VALID_METRICS = ['pages', 'referrers', 'countries', 'browsers', 'os', 'devices'] as const;
|
||||||
|
type ValidMetric = (typeof VALID_METRICS)[number];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ portSlug: string; metric: string }>;
|
||||||
|
searchParams: Promise<{ range?: string; from?: string; to?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
const { metric } = await params;
|
||||||
|
const { range, from, to } = await searchParams;
|
||||||
|
if (!VALID_METRICS.includes(metric as ValidMetric)) notFound();
|
||||||
|
return (
|
||||||
|
<MetricDetailShell
|
||||||
|
metric={metric as ValidMetric}
|
||||||
|
initialRange={range ?? '30d'}
|
||||||
|
initialFrom={from}
|
||||||
|
initialTo={to}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/api/public/email-pixel/[sendId]/route.ts
Normal file
106
src/app/api/public/email-pixel/[sendId]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { trackEvent } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/public/email-pixel/[sendId]
|
||||||
|
*
|
||||||
|
* Returns a 1×1 transparent GIF and records an open event in
|
||||||
|
* `document_send_opens` + bumps the cached aggregates on `document_sends`.
|
||||||
|
*
|
||||||
|
* Lookups are gated by `track_opens=true` on the send row, so a leaked
|
||||||
|
* sendId for an untracked email is a no-op (the pixel still returns
|
||||||
|
* 200/GIF so email clients don't surface a broken-image icon).
|
||||||
|
*
|
||||||
|
* Privacy: we deliberately don't store IP addresses or any data beyond
|
||||||
|
* user-agent + referer. Apple Mail privacy proxy pre-fetches images, so
|
||||||
|
* opens from iOS users are over-counted; image-blocking clients
|
||||||
|
* (Outlook with images disabled) under-count. Standard email-tracking
|
||||||
|
* caveats apply.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1×1 transparent GIF, base64-encoded. Generated once at module-load so
|
||||||
|
// every request returns the same buffer without re-allocating.
|
||||||
|
const TRANSPARENT_GIF = Buffer.from(
|
||||||
|
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
function gifResponse(): NextResponse {
|
||||||
|
return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/gif',
|
||||||
|
'Content-Length': String(TRANSPARENT_GIF.length),
|
||||||
|
// Tell every upstream cache to keep its hands off — we count opens
|
||||||
|
// on the FETCH itself, so any cached response is a missed open.
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
Expires: '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ sendId: string }> },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const { sendId } = await ctx.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Look up the send row; ignore unknown / un-tracked sends silently.
|
||||||
|
const sendRow = await db.query.documentSends.findFirst({
|
||||||
|
where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)),
|
||||||
|
columns: { id: true, portId: true, recipientEmail: true, documentKind: true },
|
||||||
|
});
|
||||||
|
if (!sendRow) return gifResponse();
|
||||||
|
|
||||||
|
const userAgent = req.headers.get('user-agent');
|
||||||
|
const referer = req.headers.get('referer');
|
||||||
|
|
||||||
|
// Best-effort write — never block the pixel response on a slow DB.
|
||||||
|
// The pixel must return promptly so email clients render normally.
|
||||||
|
db.insert(documentSendOpens)
|
||||||
|
.values({
|
||||||
|
portId: sendRow.portId,
|
||||||
|
sendId: sendRow.id,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
referer: referer ?? null,
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
db
|
||||||
|
.update(documentSends)
|
||||||
|
.set({
|
||||||
|
openCount: sql`${documentSends.openCount} + 1`,
|
||||||
|
firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`,
|
||||||
|
})
|
||||||
|
.where(eq(documentSends.id, sendRow.id)),
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
||||||
|
// await — fire-and-forget so the pixel response stays fast.
|
||||||
|
trackEvent(
|
||||||
|
sendRow.portId,
|
||||||
|
'email-opened',
|
||||||
|
{
|
||||||
|
sendId: sendRow.id,
|
||||||
|
documentKind: sendRow.documentKind,
|
||||||
|
},
|
||||||
|
'email://pixel',
|
||||||
|
).catch((err) => {
|
||||||
|
logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
return gifResponse();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, sendId }, 'email-pixel: unexpected error');
|
||||||
|
return gifResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
getActiveVisitors,
|
getActiveVisitors,
|
||||||
getMetric,
|
getMetric,
|
||||||
getPageviewsSeries,
|
getPageviewsSeries,
|
||||||
|
getRealtime,
|
||||||
|
getSession,
|
||||||
|
getSessionActivity,
|
||||||
|
getSessions,
|
||||||
|
getSessionsWeekly,
|
||||||
getStats,
|
getStats,
|
||||||
|
getWebsiteInfo,
|
||||||
type UmamiMetricType,
|
type UmamiMetricType,
|
||||||
} from '@/lib/services/umami.service';
|
} from '@/lib/services/umami.service';
|
||||||
|
|
||||||
@@ -31,7 +37,11 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
|
// Umami v2/v3 metric `type` values surfaced by the CRM. `path` is the
|
||||||
|
// current name for what older versions called `url` — accept both as
|
||||||
|
// inbound metric names (old clients won't break) but `path` is what the
|
||||||
|
// service forwards to Umami.
|
||||||
|
const TOP_METRIC_RX = /^top-(path|url|referrer|country|browser|os|device|event)$/;
|
||||||
|
|
||||||
function parseRange(req: NextRequest): DateRange | { error: string } {
|
function parseRange(req: NextRequest): DateRange | { error: string } {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -88,8 +98,30 @@ export const GET = withAuth(
|
|||||||
data = await getPageviewsSeries(ctx.portId, range);
|
data = await getPageviewsSeries(ctx.portId, range);
|
||||||
} else if (metric === 'active') {
|
} else if (metric === 'active') {
|
||||||
data = await getActiveVisitors(ctx.portId);
|
data = await getActiveVisitors(ctx.portId);
|
||||||
|
} else if (metric === 'realtime') {
|
||||||
|
data = await getRealtime(ctx.portId);
|
||||||
|
} else if (metric === 'website') {
|
||||||
|
data = await getWebsiteInfo(ctx.portId);
|
||||||
|
} else if (metric === 'sessions') {
|
||||||
|
const page = Number(url.searchParams.get('page') ?? 1);
|
||||||
|
const pageSize = Number(url.searchParams.get('pageSize') ?? 25);
|
||||||
|
const query = url.searchParams.get('query') ?? undefined;
|
||||||
|
data = await getSessions(ctx.portId, range, { page, pageSize, query });
|
||||||
|
} else if (metric === 'session') {
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) throw new ValidationError('Missing sessionId');
|
||||||
|
data = await getSession(ctx.portId, sessionId);
|
||||||
|
} else if (metric === 'session-activity') {
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) throw new ValidationError('Missing sessionId');
|
||||||
|
data = await getSessionActivity(ctx.portId, sessionId, range);
|
||||||
|
} else if (metric === 'sessions-weekly') {
|
||||||
|
data = await getSessionsWeekly(ctx.portId, range);
|
||||||
} else if (TOP_METRIC_RX.test(metric)) {
|
} else if (TOP_METRIC_RX.test(metric)) {
|
||||||
const type = metric.replace(/^top-/, '') as UmamiMetricType;
|
const raw = metric.replace(/^top-/, '');
|
||||||
|
// Legacy alias — older callers still send `top-url`; map to the
|
||||||
|
// Umami v3 enum name to keep them working post-rewrite.
|
||||||
|
const type = (raw === 'url' ? 'path' : raw) as UmamiMetricType;
|
||||||
const limit = Number(url.searchParams.get('limit') ?? 10);
|
const limit = Number(url.searchParams.get('limit') ?? 10);
|
||||||
data = await getMetric(ctx.portId, range, type, limit);
|
data = await getMetric(ctx.portId, range, type, limit);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
79
src/app/q/[slug]/route.ts
Normal file
79
src/app/q/[slug]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { trackedLinkClicks, trackedLinks } from '@/lib/db/schema/tracked-links';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { trackEvent } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /q/[slug]
|
||||||
|
*
|
||||||
|
* Phase 4c — tracked redirect link. Looks up the slug, records the
|
||||||
|
* click (fire-and-forget so the redirect stays fast), and 302s the
|
||||||
|
* recipient to the target URL. Unknown slugs 404 — we deliberately do
|
||||||
|
* NOT redirect anonymous traffic to a default home page since that
|
||||||
|
* would be an open-redirect risk (although `targetUrl` is admin-stored
|
||||||
|
* not user-supplied, this keeps the endpoint surface small).
|
||||||
|
*
|
||||||
|
* Cross-posts to Umami as a `link-clicked` event so marketing can see
|
||||||
|
* email click-throughs alongside their normal pageview funnel.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ slug: string }> },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const { slug } = await ctx.params;
|
||||||
|
|
||||||
|
// Slug format gate — reject obvious noise without hitting the DB.
|
||||||
|
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
|
||||||
|
return new NextResponse('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await db.query.trackedLinks.findFirst({
|
||||||
|
where: eq(trackedLinks.slug, slug),
|
||||||
|
columns: { id: true, portId: true, targetUrl: true, sendId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) return new NextResponse('Not found', { status: 404 });
|
||||||
|
|
||||||
|
const userAgent = req.headers.get('user-agent');
|
||||||
|
const referer = req.headers.get('referer');
|
||||||
|
|
||||||
|
// Fire-and-forget click recording; the redirect doesn't wait on DB.
|
||||||
|
db.insert(trackedLinkClicks)
|
||||||
|
.values({
|
||||||
|
trackedLinkId: link.id,
|
||||||
|
portId: link.portId,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
referer: referer ?? null,
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
db
|
||||||
|
.update(trackedLinks)
|
||||||
|
.set({
|
||||||
|
clickCount: sql`${trackedLinks.clickCount} + 1`,
|
||||||
|
firstClickedAt: sql`COALESCE(${trackedLinks.firstClickedAt}, NOW())`,
|
||||||
|
lastClickedAt: sql`NOW()`,
|
||||||
|
})
|
||||||
|
.where(eq(trackedLinks.id, link.id)),
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn({ err, slug }, '/q: failed to record click');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Umami cross-post for funnel analysis. Soft-fails.
|
||||||
|
trackEvent(
|
||||||
|
link.portId,
|
||||||
|
'link-clicked',
|
||||||
|
{
|
||||||
|
slug,
|
||||||
|
sendId: link.sendId ?? null,
|
||||||
|
},
|
||||||
|
`/q/${slug}`,
|
||||||
|
).catch((err) => {
|
||||||
|
logger.debug({ err, slug }, '/q: umami cross-post failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(link.targetUrl, 302);
|
||||||
|
}
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact "Website at a glance" tile for the main sales dashboard. Shows
|
* Compact "Website at a glance" tile for the main sales dashboard. Shows
|
||||||
* pageviews today + active visitors right now + a deep-link to the full
|
* pageviews for the dashboard's current range + active visitors right
|
||||||
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
|
* now + a deep-link to the full /website-analytics page. Soft-fails
|
||||||
* configured for this port - so the dashboard doesn't get cluttered with
|
* (renders nothing) when Umami isn't configured for this port — the
|
||||||
* a "configure Umami" prompt that the user already saw on the dedicated
|
* configure-prompt lives on the dedicated page, not the dashboard.
|
||||||
* page.
|
*
|
||||||
|
* When an Umami call fails (auth, network, shape) the tile renders a
|
||||||
|
* dash "—" instead of "0" so the rep can tell error from no-data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Globe, ArrowRight } from 'lucide-react';
|
import { Globe, ArrowRight, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
@@ -19,23 +21,42 @@ import {
|
|||||||
useUmamiActive,
|
useUmamiActive,
|
||||||
useUmamiStats,
|
useUmamiStats,
|
||||||
} from '@/components/website-analytics/use-website-analytics';
|
} from '@/components/website-analytics/use-website-analytics';
|
||||||
|
import type { DateRange } from '@/lib/analytics/range';
|
||||||
|
import { isCustomRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
export function WebsiteGlanceTile() {
|
interface Props {
|
||||||
|
range?: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||||
|
today: 'Today',
|
||||||
|
'7d': '7 days',
|
||||||
|
'30d': '30 days',
|
||||||
|
'90d': '90 days',
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortRangeLabel(range: DateRange): string {
|
||||||
|
if (isCustomRange(range)) return 'Custom range';
|
||||||
|
return RANGE_LABELS[range];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteGlanceTile({ range = '30d' }: Props) {
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const stats = useUmamiStats('today');
|
const stats = useUmamiStats(range);
|
||||||
const active = useUmamiActive('today');
|
const active = useUmamiActive(range);
|
||||||
|
|
||||||
// Hide the tile entirely if Umami isn't configured - this dashboard is
|
|
||||||
// for sales, not for prompting the operator into integration setup.
|
|
||||||
// The API surfaces `notConfigured: true` on a 200 response so React
|
|
||||||
// Query doesn't retry-loop (a prior 409-throw caused server hangs).
|
|
||||||
if (stats.data?.notConfigured || active.data?.notConfigured) {
|
if (stats.data?.notConfigured || active.data?.notConfigured) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = stats.data?.data?.pageviews?.value ?? 0;
|
// Umami v3 returns flat numbers — `data?.data?.pageviews` is a number,
|
||||||
const activeNow = active.data?.data?.visitors ?? 0;
|
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
|
||||||
|
// comparison values into a sibling `comparison` block.
|
||||||
|
const pageviews = stats.data?.data?.pageviews;
|
||||||
|
const activeNow = active.data?.data?.visitors;
|
||||||
const loading = stats.isLoading || active.isLoading;
|
const loading = stats.isLoading || active.isLoading;
|
||||||
|
const statsErrored = stats.isError;
|
||||||
|
const activeErrored = active.isError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -49,22 +70,36 @@ export function WebsiteGlanceTile() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||||
<Globe className="h-3 w-3" aria-hidden />
|
<Globe className="h-3 w-3" aria-hidden />
|
||||||
Website today
|
Website · {shortRangeLabel(range)}
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="mt-2 h-7 w-20" aria-hidden />
|
<Skeleton className="mt-2 h-7 w-20" aria-hidden />
|
||||||
|
) : statsErrored || pageviews === undefined ? (
|
||||||
|
<div
|
||||||
|
className="mt-1 flex items-center gap-1.5 text-sm text-warning sm:mt-2"
|
||||||
|
title={stats.error instanceof Error ? stats.error.message : 'Umami unavailable'}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3.5" aria-hidden />
|
||||||
|
Umami unavailable
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
|
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
|
||||||
{today.toLocaleString()}
|
{pageviews.toLocaleString()}
|
||||||
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
|
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
{activeErrored ? (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
<span className="text-warning">live count unavailable</span>
|
||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
) : (
|
||||||
</span>
|
<>
|
||||||
{activeNow} active right now
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
{activeNow ?? 0} active right now
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
|
|||||||
206
src/components/website-analytics/metric-detail-shell.tsx
Normal file
206
src/components/website-analytics/metric-detail-shell.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail page shell rendered at /{portSlug}/website-analytics/{metric}.
|
||||||
|
* Shows the full ranked list (no top-10 cap) for one Umami metric, plus
|
||||||
|
* a back-link and a date range picker that mirrors the parent page.
|
||||||
|
*
|
||||||
|
* The metric slug in the URL maps to a Umami metric type. Country rows
|
||||||
|
* are rebadged to full English names; page paths get the same Homepage
|
||||||
|
* substitution the dashboard does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||||
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
const METRIC_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ umamiMetric: string; title: string; emptyLabel: string; transform?: (x: string) => string }
|
||||||
|
> = {
|
||||||
|
pages: {
|
||||||
|
umamiMetric: 'top-path',
|
||||||
|
title: 'All pages',
|
||||||
|
emptyLabel: '(unknown)',
|
||||||
|
transform: (x) => (x === '/' ? 'Homepage' : x),
|
||||||
|
},
|
||||||
|
referrers: {
|
||||||
|
umamiMetric: 'top-referrer',
|
||||||
|
title: 'All referrers',
|
||||||
|
emptyLabel: '(direct)',
|
||||||
|
},
|
||||||
|
countries: {
|
||||||
|
umamiMetric: 'top-country',
|
||||||
|
title: 'All countries',
|
||||||
|
emptyLabel: '(unknown)',
|
||||||
|
transform: (x) => getCountryName(x, 'en'),
|
||||||
|
},
|
||||||
|
browsers: {
|
||||||
|
umamiMetric: 'top-browser',
|
||||||
|
title: 'All browsers',
|
||||||
|
emptyLabel: '(unknown)',
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
umamiMetric: 'top-os',
|
||||||
|
title: 'All operating systems',
|
||||||
|
emptyLabel: '(unknown)',
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
umamiMetric: 'top-device',
|
||||||
|
title: 'All devices',
|
||||||
|
emptyLabel: '(unknown)',
|
||||||
|
transform: (x) => (x === '' ? 'Unknown' : x.charAt(0).toUpperCase() + x.slice(1)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MetricResponse {
|
||||||
|
metric: string;
|
||||||
|
range: DateRange;
|
||||||
|
data: UmamiMetricRow[] | null;
|
||||||
|
notConfigured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
metric: string;
|
||||||
|
initialRange: string;
|
||||||
|
initialFrom?: string;
|
||||||
|
initialTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricDetailShell({ metric, initialRange, initialFrom, initialTo }: Props) {
|
||||||
|
const cfg = METRIC_CONFIG[metric];
|
||||||
|
const router = useRouter();
|
||||||
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const portId = useUIStore((s) => s.currentPortId);
|
||||||
|
|
||||||
|
const [range, setRange] = useState<DateRange>(() =>
|
||||||
|
parseInitialRange(initialRange, initialFrom, initialTo),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleRangeChange(next: DateRange) {
|
||||||
|
setRange(next);
|
||||||
|
// Mirror the picker choice back into the URL so refresh / share / back
|
||||||
|
// all preserve the time window the user picked.
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (isCustomRange(next)) {
|
||||||
|
sp.set('range', 'custom');
|
||||||
|
sp.set('from', next.from);
|
||||||
|
sp.set('to', next.to);
|
||||||
|
} else {
|
||||||
|
sp.set('range', next);
|
||||||
|
}
|
||||||
|
router.replace(`/${portSlug}/website-analytics/${metric}?${sp.toString()}` as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = useQuery<MetricResponse>({
|
||||||
|
queryKey: ['website-analytics', cfg?.umamiMetric, range, portId, 'detail'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<MetricResponse>(
|
||||||
|
`/api/v1/website-analytics?metric=${cfg!.umamiMetric}&${rangeToQuery(range)}&limit=500`,
|
||||||
|
),
|
||||||
|
enabled: !!portId && !!cfg,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
return <div className="p-8 text-sm text-muted-foreground">Unknown metric.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = query.data?.data ?? null;
|
||||||
|
const max = rows && rows.length > 0 ? rows[0]!.y : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-start">
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/website-analytics?${rangeToQuery(range)}` as never}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" aria-hidden />
|
||||||
|
Back to website analytics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<PageHeader
|
||||||
|
title={cfg.title}
|
||||||
|
eyebrow="Website analytics"
|
||||||
|
variant="gradient"
|
||||||
|
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{query.isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-5 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !rows || rows.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No data in this range.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const pct = (row.y / max) * 100;
|
||||||
|
const raw = row.x?.trim() || cfg.emptyLabel;
|
||||||
|
const label = cfg.transform ? cfg.transform(raw) : raw;
|
||||||
|
return (
|
||||||
|
<li key={`${row.x}-${i}`} className="text-sm">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">
|
||||||
|
<span className="mr-2 inline-block w-6 tabular-nums text-muted-foreground">
|
||||||
|
{i + 1}.
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{row.y.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-brand"
|
||||||
|
style={{ width: `${Math.max(2, pct)}%` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInitialRange(rawRange: string, from?: string, to?: string): DateRange {
|
||||||
|
if (rawRange === 'custom' && from && to) {
|
||||||
|
return { kind: 'custom', from, to };
|
||||||
|
}
|
||||||
|
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
|
||||||
|
return rawRange;
|
||||||
|
}
|
||||||
|
return '30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeToQuery(range: DateRange): string {
|
||||||
|
if (isCustomRange(range)) {
|
||||||
|
return `range=custom&from=${range.from}&to=${range.to}`;
|
||||||
|
}
|
||||||
|
return `range=${range}`;
|
||||||
|
}
|
||||||
@@ -33,13 +33,16 @@ export function PageviewsChart({ data }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge the two series (Umami returns them separately) into one row per
|
// Merge the two series (Umami returns them separately when `compare` is
|
||||||
// bucket so we can drive a single chart.
|
// requested) into one row per bucket so we can drive a single chart.
|
||||||
|
// `sessions` is optional on Umami v3 — only present when the request
|
||||||
|
// included a comparison directive. Guard the read so an undefined
|
||||||
|
// array doesn't crash the chart.
|
||||||
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
|
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
|
||||||
for (const p of data.pageviews) {
|
for (const p of data.pageviews) {
|
||||||
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
|
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
|
||||||
}
|
}
|
||||||
for (const s of data.sessions) {
|
for (const s of data.sessions ?? []) {
|
||||||
const row = byX.get(s.x);
|
const row = byX.get(s.x);
|
||||||
if (row) row.sessions = s.y;
|
if (row) row.sessions = s.y;
|
||||||
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
|
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
|
||||||
@@ -78,6 +81,7 @@ export function PageviewsChart({ data }: Props) {
|
|||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
|
labelFormatter={formatTooltipLabel}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Area
|
<Area
|
||||||
@@ -101,11 +105,24 @@ export function PageviewsChart({ data }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
|
/** Compact tick labels: drop the timestamp entirely — for multi-day ranges
|
||||||
|
* the hour component is meaningless (a "day" bucket aggregates the whole
|
||||||
|
* day) and just causes visual crowding. Keep MM-DD. */
|
||||||
function formatXTick(value: string): string {
|
function formatXTick(value: string): string {
|
||||||
// Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD".
|
return value.slice(5, 10); // "MM-DD"
|
||||||
if (value.length >= 16) {
|
}
|
||||||
return value.slice(5, 16); // "MM-DD HH:mm"
|
|
||||||
}
|
/** Tooltip header: format "2026-03-30 00:00:00" → "Mar 30, 2026" so the
|
||||||
return value.slice(5); // "MM-DD"
|
* meaningless 00:00:00 timestamp doesn't show. */
|
||||||
|
function formatTooltipLabel(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
const datePart = value.slice(0, 10); // "YYYY-MM-DD"
|
||||||
|
const d = new Date(`${datePart}T00:00:00Z`);
|
||||||
|
if (isNaN(d.getTime())) return datePart;
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
245
src/components/website-analytics/realtime-panel.tsx
Normal file
245
src/components/website-analytics/realtime-panel.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realtime panel — Umami's "what's happening RIGHT NOW" view, surfaced
|
||||||
|
* as a collapsible card at the top of the website-analytics page.
|
||||||
|
*
|
||||||
|
* Folds in five things from Umami's /api/realtime/<id> endpoint:
|
||||||
|
* - Totals strip (visitors / views / events / countries in the last 30m)
|
||||||
|
* - Top URLs being viewed
|
||||||
|
* - Top countries
|
||||||
|
* - Top referrers
|
||||||
|
* - Recent event stream (pageviews + named events as they arrive)
|
||||||
|
*
|
||||||
|
* Polling pauses when the card is collapsed so we're not hammering
|
||||||
|
* Umami at 5 s intervals while no one is looking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, Globe, Activity, MapPin, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { useUmamiRealtime } from './use-website-analytics';
|
||||||
|
|
||||||
|
export function RealtimePanel() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const query = useUmamiRealtime(open);
|
||||||
|
const data = query.data?.data ?? null;
|
||||||
|
|
||||||
|
// Hide the entire bar when Umami reports a quiet 30-minute window —
|
||||||
|
// a "Live activity (0 visitors)" header is just noise. We still poll
|
||||||
|
// every 60 s while hidden so the bar reappears the moment traffic
|
||||||
|
// arrives.
|
||||||
|
const isQuiet = !!data && data.totals.visitors === 0 && data.events.length === 0;
|
||||||
|
if (isQuiet && !open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40 sm:px-5"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">Live activity</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{open
|
||||||
|
? 'Auto-refreshing every 5s · last 30 minutes'
|
||||||
|
: 'Click to expand — top pages, countries, and a live event stream'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<CardContent className="border-t border-border pt-4 sm:pt-6">
|
||||||
|
{query.isLoading ? (
|
||||||
|
<Skeleton className="h-[300px] w-full" />
|
||||||
|
) : !data ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No realtime data available.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Totals strip */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label="Visitors" value={data.totals.visitors} />
|
||||||
|
<Stat label="Pageviews" value={data.totals.views} />
|
||||||
|
<Stat label="Events" value={data.totals.events} />
|
||||||
|
<Stat label="Countries" value={data.totals.countries} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three-column ranked-list strip */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<RankList
|
||||||
|
icon={<ExternalLink className="size-3.5" aria-hidden />}
|
||||||
|
title="Top pages right now"
|
||||||
|
rows={recordToRows(data.urls).map((r) => ({
|
||||||
|
...r,
|
||||||
|
label: r.label === '/' ? 'Homepage' : r.label,
|
||||||
|
}))}
|
||||||
|
emptyLabel="No pageviews yet"
|
||||||
|
/>
|
||||||
|
<RankList
|
||||||
|
icon={<MapPin className="size-3.5" aria-hidden />}
|
||||||
|
title="Top countries"
|
||||||
|
rows={recordToRows(data.countries).map((r) => ({
|
||||||
|
...r,
|
||||||
|
label: getCountryName(r.label, 'en') || r.label || 'Unknown',
|
||||||
|
}))}
|
||||||
|
emptyLabel="No country data yet"
|
||||||
|
/>
|
||||||
|
<RankList
|
||||||
|
icon={<Globe className="size-3.5" aria-hidden />}
|
||||||
|
title="Top referrers"
|
||||||
|
rows={recordToRows(data.referrers).map((r) => ({
|
||||||
|
...r,
|
||||||
|
label: r.label || '(direct)',
|
||||||
|
}))}
|
||||||
|
emptyLabel="No referrers yet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent event stream */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Activity className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
Recent activity
|
||||||
|
</h3>
|
||||||
|
{data.events.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border py-6 text-center text-xs text-muted-foreground">
|
||||||
|
No events in the last 30 minutes.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-1.5 text-xs">
|
||||||
|
{data.events.slice(0, 20).map((ev, i) => (
|
||||||
|
<li
|
||||||
|
key={`${ev.createdAt}-${i}`}
|
||||||
|
className="flex items-baseline justify-between gap-2 rounded-md bg-muted/40 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 truncate">
|
||||||
|
<span className="font-medium">
|
||||||
|
{ev.eventName
|
||||||
|
? `Event: ${ev.eventName}`
|
||||||
|
: !ev.urlPath || ev.urlPath === '/'
|
||||||
|
? 'Homepage'
|
||||||
|
: ev.urlPath}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{[ev.country && getCountryName(ev.country, 'en'), ev.browser, ev.device]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{fmtAgo(ev.createdAt)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xl font-semibold tabular-nums">{value.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankRow {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankList({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
rows: RankRow[];
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const max = rows[0]?.value ?? 1;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border py-3 text-center text-xs text-muted-foreground">
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{rows.slice(0, 5).map((r) => {
|
||||||
|
const pct = (r.value / max) * 100;
|
||||||
|
return (
|
||||||
|
<li key={r.label}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{r.label}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{r.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-brand"
|
||||||
|
style={{ width: `${Math.max(2, pct)}%` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToRows(rec: Record<string, number>): RankRow[] {
|
||||||
|
return Object.entries(rec)
|
||||||
|
.map(([label, value]) => ({ label, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(iso: string): string {
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (isNaN(t)) return iso;
|
||||||
|
const diff = Date.now() - t;
|
||||||
|
const seconds = Math.max(1, Math.round(diff / 1000));
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
106
src/components/website-analytics/session-detail-sheet.tsx
Normal file
106
src/components/website-analytics/session-detail-sheet.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-side Sheet with the full activity stream for one session.
|
||||||
|
* Driven by /api/v1/website-analytics?metric=session-activity. Each row
|
||||||
|
* is a pageview or custom event in chronological order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useUmamiSessionActivity } from './use-website-analytics';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import type { DateRange } from '@/lib/analytics/range';
|
||||||
|
import type { UmamiSession } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
session: UmamiSession | null;
|
||||||
|
range: DateRange;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionDetailSheet({ session, range, onClose }: Props) {
|
||||||
|
const activityQuery = useUmamiSessionActivity(range, session?.id ?? null);
|
||||||
|
const activity = activityQuery.data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={!!session} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Session detail</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{session ? (
|
||||||
|
<div className="mt-4 space-y-5">
|
||||||
|
{/* Top facts */}
|
||||||
|
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||||
|
<DtDd label="Location">
|
||||||
|
{getCountryName(session.country, 'en') || 'Unknown'}
|
||||||
|
{session.city ? ` · ${session.city}` : ''}
|
||||||
|
</DtDd>
|
||||||
|
<DtDd label="Device">{session.device}</DtDd>
|
||||||
|
<DtDd label="Browser">{session.browser}</DtDd>
|
||||||
|
<DtDd label="OS">{session.os}</DtDd>
|
||||||
|
<DtDd label="Screen">{session.screen || '—'}</DtDd>
|
||||||
|
<DtDd label="Language">{session.language || '—'}</DtDd>
|
||||||
|
<DtDd label="First visit">{fmtTime(session.firstAt)}</DtDd>
|
||||||
|
<DtDd label="Last visit">{fmtTime(session.lastAt)}</DtDd>
|
||||||
|
<DtDd label="Visits">{session.visits.toLocaleString()}</DtDd>
|
||||||
|
<DtDd label="Pageviews">{session.views.toLocaleString()}</DtDd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{/* Activity stream */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Activity</h3>
|
||||||
|
{activityQuery.isLoading ? (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : activity.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
No activity recorded for this session.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="mt-2 space-y-1 border-l border-border pl-3">
|
||||||
|
{activity.map((row) => (
|
||||||
|
<li key={row.eventId} className="relative text-xs">
|
||||||
|
<span className="absolute -left-[15px] top-1 inline-block size-2 rounded-full bg-brand" />
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{row.eventName ? `Event: ${row.eventName}` : row.urlPath || '/'}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{fmtTime(row.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.eventName && row.urlPath ? (
|
||||||
|
<div className="text-muted-foreground">{row.urlPath}</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DtDd({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-foreground">{children}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
}
|
||||||
150
src/components/website-analytics/sessions-list.tsx
Normal file
150
src/components/website-analytics/sessions-list.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent-sessions card for the website-analytics page. Paginated list
|
||||||
|
* of visitor sessions (one row per unique session) with click-through to
|
||||||
|
* a detail sheet showing the full activity stream.
|
||||||
|
*
|
||||||
|
* Umami's session model: one row per anonymous-device-fingerprint+IP+UA
|
||||||
|
* combination, with first/last visit timestamps + visit/view counts +
|
||||||
|
* geo + browser/os/device. The detail page shows the per-event stream
|
||||||
|
* (pageviews + custom events) within that session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Globe, Smartphone, Monitor, Tablet, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import { useUmamiSessions } from './use-website-analytics';
|
||||||
|
import { SessionDetailSheet } from './session-detail-sheet';
|
||||||
|
import { type DateRange } from '@/lib/analytics/range';
|
||||||
|
import type { UmamiSession } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsList({ range }: Props) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selected, setSelected] = useState<UmamiSession | null>(null);
|
||||||
|
const pageSize = 15;
|
||||||
|
const query = useUmamiSessions(range, { page, pageSize });
|
||||||
|
|
||||||
|
const sessions = query.data?.data?.data ?? [];
|
||||||
|
const total = query.data?.data?.count ?? 0;
|
||||||
|
const hasMore = page * pageSize < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recent sessions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No sessions in this range.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{sessions.map((s, i) => (
|
||||||
|
// Umami's sessions endpoint can return rows with the
|
||||||
|
// same session id within a page when activity straddles
|
||||||
|
// a bucket boundary. Compose the key to dedupe.
|
||||||
|
<li key={`${s.id}-${i}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(s)}
|
||||||
|
className="group flex w-full items-center justify-between gap-3 py-3 text-left transition hover:bg-muted/40 -mx-2 px-2 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<DeviceIcon device={s.device} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{getCountryName(s.country, 'en') || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
{s.city ? (
|
||||||
|
<span className="text-muted-foreground">{s.city}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="tabular-nums">{s.views.toLocaleString()} views</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="size-4 opacity-0 transition group-hover:opacity-100"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of{' '}
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={!hasMore}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SessionDetailSheet session={selected} range={range} onClose={() => setSelected(null)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceIcon({ device }: { device: string }) {
|
||||||
|
const cls = 'size-5 shrink-0 text-muted-foreground';
|
||||||
|
switch (device.toLowerCase()) {
|
||||||
|
case 'mobile':
|
||||||
|
return <Smartphone className={cls} aria-hidden />;
|
||||||
|
case 'tablet':
|
||||||
|
return <Tablet className={cls} aria-hidden />;
|
||||||
|
case 'desktop':
|
||||||
|
case 'laptop':
|
||||||
|
return <Monitor className={cls} aria-hidden />;
|
||||||
|
default:
|
||||||
|
return <Globe className={cls} aria-hidden />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||||
@@ -10,6 +13,11 @@ interface Props {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Label substituted when `x` is empty (e.g. direct traffic referrers). */
|
/** Label substituted when `x` is empty (e.g. direct traffic referrers). */
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
|
/** Optional "View all" link target. When set, renders a link in the
|
||||||
|
* card header that opens a full ranked-list page for this metric. */
|
||||||
|
viewAllHref?: string;
|
||||||
|
/** Cap for the inline list (default 10). The full page uses no cap. */
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,11 +26,31 @@ interface Props {
|
|||||||
* scaled to the largest count in the set so the visual density tells
|
* scaled to the largest count in the set so the visual density tells
|
||||||
* the same story at a glance as the numbers.
|
* the same story at a glance as the numbers.
|
||||||
*/
|
*/
|
||||||
export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) {
|
export function TopList({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
defaultLabel = '-',
|
||||||
|
viewAllHref,
|
||||||
|
limit = 10,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
{viewAllHref ? (
|
||||||
|
<Link
|
||||||
|
// typedRoutes is enabled — viewAllHref is constructed at the
|
||||||
|
// call site from string interpolation, so opt out of the
|
||||||
|
// literal-string check here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={viewAllHref as any}
|
||||||
|
className="inline-flex items-center gap-0.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="size-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -36,7 +64,7 @@ export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) {
|
|||||||
<div className="py-6 text-center text-sm text-muted-foreground">No data</div>
|
<div className="py-6 text-center text-sm text-muted-foreground">No data</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{rows.slice(0, 10).map((row, i) => {
|
{rows.slice(0, limit).map((row, i) => {
|
||||||
const max = rows[0]?.y ?? 1;
|
const max = rows[0]?.y ?? 1;
|
||||||
const pct = (row.y / max) * 100;
|
const pct = (row.y / max) * 100;
|
||||||
const label = row.x?.trim() || defaultLabel;
|
const label = row.x?.trim() || defaultLabel;
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import type {
|
|||||||
UmamiActiveVisitors,
|
UmamiActiveVisitors,
|
||||||
UmamiMetricRow,
|
UmamiMetricRow,
|
||||||
UmamiPageviewsSeries,
|
UmamiPageviewsSeries,
|
||||||
|
UmamiRealtime,
|
||||||
|
UmamiSession,
|
||||||
|
UmamiSessionActivity,
|
||||||
|
UmamiSessionsPage,
|
||||||
UmamiStats,
|
UmamiStats,
|
||||||
|
UmamiWebsiteInfo,
|
||||||
} from '@/lib/services/umami.service';
|
} from '@/lib/services/umami.service';
|
||||||
|
|
||||||
interface MetricResponse<T> {
|
interface MetricResponse<T> {
|
||||||
@@ -49,6 +54,12 @@ function useUmamiQuery<T>(
|
|||||||
* metrics whose response is range-independent (e.g. active visitors)
|
* metrics whose response is range-independent (e.g. active visitors)
|
||||||
* so the cache isn't fragmented across each date the user has picked. */
|
* so the cache isn't fragmented across each date the user has picked. */
|
||||||
cacheKeySegment?: unknown,
|
cacheKeySegment?: unknown,
|
||||||
|
/** Optional auto-refresh interval. Used for the live active-visitors
|
||||||
|
* badge so the count ticks without a page reload. */
|
||||||
|
refetchInterval?: number,
|
||||||
|
/** Additional enabled gate (e.g. for session-activity which needs a
|
||||||
|
* selected sessionId before firing). ANDed with the port-id check. */
|
||||||
|
enabledGate = true,
|
||||||
) {
|
) {
|
||||||
const portId = useUIStore((s) => s.currentPortId);
|
const portId = useUIStore((s) => s.currentPortId);
|
||||||
return useQuery<MetricResponse<T>>({
|
return useQuery<MetricResponse<T>>({
|
||||||
@@ -59,7 +70,8 @@ function useUmamiQuery<T>(
|
|||||||
),
|
),
|
||||||
staleTime: 30_000, // umami data refreshes constantly; short stale time
|
staleTime: 30_000, // umami data refreshes constantly; short stale time
|
||||||
retry: 1,
|
retry: 1,
|
||||||
enabled: !!portId,
|
enabled: !!portId && enabledGate,
|
||||||
|
...(refetchInterval ? { refetchInterval } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +85,84 @@ export const useUmamiPageviews = (range: DateRange) =>
|
|||||||
// website-analytics shell (any selected range) share one cache entry instead
|
// website-analytics shell (any selected range) share one cache entry instead
|
||||||
// of fragmenting it across every range the user picks.
|
// of fragmenting it across every range the user picks.
|
||||||
export const useUmamiActive = (range: DateRange) =>
|
export const useUmamiActive = (range: DateRange) =>
|
||||||
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed');
|
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed', 30_000);
|
||||||
|
|
||||||
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
|
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
|
||||||
useUmamiQuery<UmamiMetricRow[]>('top-url', range, `&limit=${limit}`);
|
useUmamiQuery<UmamiMetricRow[]>('top-path', range, `&limit=${limit}`);
|
||||||
export const useUmamiTopReferrers = (range: DateRange, limit = 10) =>
|
export const useUmamiTopReferrers = (range: DateRange, limit = 10) =>
|
||||||
useUmamiQuery<UmamiMetricRow[]>('top-referrer', range, `&limit=${limit}`);
|
useUmamiQuery<UmamiMetricRow[]>('top-referrer', range, `&limit=${limit}`);
|
||||||
export const useUmamiTopCountries = (range: DateRange, limit = 10) =>
|
export const useUmamiTopCountries = (range: DateRange, limit = 10) =>
|
||||||
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=${limit}`);
|
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=${limit}`);
|
||||||
|
|
||||||
|
// World map needs ALL countries with traffic, not just the top 10 the list
|
||||||
|
// uses. Umami caps `limit` server-side around 500; 250 covers every ISO
|
||||||
|
// country we'll ever see in one request.
|
||||||
|
export const useUmamiAllCountries = (range: DateRange) =>
|
||||||
|
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=250`, `all-${range}`);
|
||||||
|
export const useUmamiTopBrowsers = (range: DateRange, limit = 10) =>
|
||||||
|
useUmamiQuery<UmamiMetricRow[]>('top-browser', range, `&limit=${limit}`);
|
||||||
|
export const useUmamiTopOS = (range: DateRange, limit = 10) =>
|
||||||
|
useUmamiQuery<UmamiMetricRow[]>('top-os', range, `&limit=${limit}`);
|
||||||
|
export const useUmamiTopDevices = (range: DateRange, limit = 10) =>
|
||||||
|
useUmamiQuery<UmamiMetricRow[]>('top-device', range, `&limit=${limit}`);
|
||||||
|
|
||||||
|
// Website metadata (name + domain). Range-independent; long stale time
|
||||||
|
// since the domain rarely changes.
|
||||||
|
export const useUmamiWebsiteInfo = (range: DateRange) =>
|
||||||
|
useUmamiQuery<UmamiWebsiteInfo>('website', range, '', 'website-info');
|
||||||
|
|
||||||
|
// Phase 2 — sessions surface. Paginated list of recent sessions plus
|
||||||
|
// per-session detail + activity stream + weekly engagement heatmap.
|
||||||
|
export const useUmamiSessions = (
|
||||||
|
range: DateRange,
|
||||||
|
opts: { page?: number; pageSize?: number; query?: string } = {},
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.page) params.set('page', String(opts.page));
|
||||||
|
if (opts.pageSize) params.set('pageSize', String(opts.pageSize));
|
||||||
|
if (opts.query) params.set('query', opts.query);
|
||||||
|
const suffix = params.toString() ? `&${params.toString()}` : '';
|
||||||
|
const cacheKey = `${range}-${opts.page ?? 1}-${opts.pageSize ?? 25}-${opts.query ?? ''}`;
|
||||||
|
return useUmamiQuery<UmamiSessionsPage>('sessions', range, suffix, cacheKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUmamiSession = (range: DateRange, sessionId: string | null) => {
|
||||||
|
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
|
||||||
|
return useUmamiQuery<UmamiSession>(
|
||||||
|
'session',
|
||||||
|
range,
|
||||||
|
suffix,
|
||||||
|
`session-${sessionId ?? 'none'}`,
|
||||||
|
undefined,
|
||||||
|
!!sessionId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUmamiSessionActivity = (range: DateRange, sessionId: string | null) => {
|
||||||
|
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
|
||||||
|
return useUmamiQuery<UmamiSessionActivity[]>(
|
||||||
|
'session-activity',
|
||||||
|
range,
|
||||||
|
suffix,
|
||||||
|
`session-activity-${sessionId ?? 'none'}`,
|
||||||
|
undefined,
|
||||||
|
!!sessionId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUmamiSessionsWeekly = (range: DateRange) =>
|
||||||
|
useUmamiQuery<number[][]>('sessions-weekly', range);
|
||||||
|
|
||||||
|
// Realtime panel — Umami's /api/realtime endpoint returns last-30-min
|
||||||
|
// activity. Two cadences: 5 s when the panel is expanded (so it feels
|
||||||
|
// live) and 60 s when collapsed (so we still know whether to show the
|
||||||
|
// "Live activity" bar at all — the bar is hidden entirely when there
|
||||||
|
// are zero visitors and zero events in the last 30 minutes).
|
||||||
|
export const useUmamiRealtime = (expanded: boolean) =>
|
||||||
|
useUmamiQuery<UmamiRealtime>(
|
||||||
|
'realtime',
|
||||||
|
'today',
|
||||||
|
'',
|
||||||
|
'realtime-fixed',
|
||||||
|
expanded ? 5_000 : 60_000,
|
||||||
|
);
|
||||||
|
|||||||
202
src/components/website-analytics/visitor-world-map.tsx
Normal file
202
src/components/website-analytics/visitor-world-map.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choropleth world map of visitor counts per country. Powers a single card
|
||||||
|
* on the website-analytics page; hover any country for the visitor count,
|
||||||
|
* click to filter the rest of the page to that country.
|
||||||
|
*
|
||||||
|
* Uses ECharts' own world.json (the GeoJSON shipped with their public
|
||||||
|
* examples) — pre-cleaned, no antimeridian artifacts. Country features
|
||||||
|
* are matched on `properties.name` (English country name from the source).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { MapChart } from 'echarts/charts';
|
||||||
|
import {
|
||||||
|
GeoComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
TitleComponent,
|
||||||
|
} from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import type { FeatureCollection } from 'geojson';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
|
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
MapChart,
|
||||||
|
GeoComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
TitleComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ReactEChartsCore = dynamic(() => import('echarts-for-react/lib/core'), { ssr: false });
|
||||||
|
|
||||||
|
let registrationPromise: Promise<void> | null = null;
|
||||||
|
async function ensureWorldMapRegistered(): Promise<void> {
|
||||||
|
if (registrationPromise) return registrationPromise;
|
||||||
|
registrationPromise = (async () => {
|
||||||
|
const res = await fetch('/world-map/echarts-world.json');
|
||||||
|
const geo = (await res.json()) as FeatureCollection;
|
||||||
|
echarts.registerMap('world', { geoJSON: geo as unknown as object } as never);
|
||||||
|
})();
|
||||||
|
return registrationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: UmamiMetricRow[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
onCountryClick?: (iso2: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisitorWorldMap({ rows, loading, onCountryClick }: Props) {
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
ensureWorldMapRegistered().then(() => {
|
||||||
|
if (!cancelled) setMapReady(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!rows) return [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
name: getCountryName(r.x, 'en'),
|
||||||
|
value: r.y,
|
||||||
|
iso2: r.x,
|
||||||
|
}));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const maxValue = useMemo(
|
||||||
|
() => (data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const option = useMemo(
|
||||||
|
() => ({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params: { name: string; value?: number }) =>
|
||||||
|
params.value === undefined || isNaN(params.value)
|
||||||
|
? `${params.name}<br/><span style="color:#94a3b8">No visitors</span>`
|
||||||
|
: `${params.name}<br/><strong>${params.value.toLocaleString()}</strong> visitor${params.value === 1 ? '' : 's'}`,
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
textStyle: { color: '#f1f5f9', fontSize: 12 },
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
type: 'piecewise',
|
||||||
|
min: 0,
|
||||||
|
max: maxValue,
|
||||||
|
left: 16,
|
||||||
|
bottom: 12,
|
||||||
|
orient: 'horizontal',
|
||||||
|
textStyle: { color: '#64748b', fontSize: 10 },
|
||||||
|
inRange: {
|
||||||
|
color: ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1d4ed8', '#1e3a8a'],
|
||||||
|
},
|
||||||
|
itemWidth: 18,
|
||||||
|
itemHeight: 10,
|
||||||
|
itemGap: 2,
|
||||||
|
showLabel: true,
|
||||||
|
// Bucket counts into 5 piecewise segments so the legend reads
|
||||||
|
// like a discrete heat-scale rather than a hard-to-eyeball
|
||||||
|
// gradient bar.
|
||||||
|
pieces: bucketizeMax(maxValue),
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'map',
|
||||||
|
map: 'world',
|
||||||
|
roam: true,
|
||||||
|
scaleLimit: { min: 0.8, max: 8 },
|
||||||
|
aspectScale: 0.85,
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: '#f1f5f9',
|
||||||
|
borderColor: '#cbd5e1',
|
||||||
|
borderWidth: 0.4,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { areaColor: '#fbbf24', borderColor: '#92400e' },
|
||||||
|
label: { show: false },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
itemStyle: { areaColor: '#f97316' },
|
||||||
|
label: { show: false },
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[data, maxValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEvents = useMemo(
|
||||||
|
() => ({
|
||||||
|
click: (params: { data?: { iso2?: string } }) => {
|
||||||
|
const iso2 = params?.data?.iso2;
|
||||||
|
if (iso2 && onCountryClick) onCountryClick(iso2);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[onCountryClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Visitors by country</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading || !mapReady ? (
|
||||||
|
<Skeleton className="h-[560px] w-full" />
|
||||||
|
) : (
|
||||||
|
<ReactEChartsCore
|
||||||
|
echarts={echarts}
|
||||||
|
option={option}
|
||||||
|
onEvents={onEvents}
|
||||||
|
style={{ height: 560, width: '100%' }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket the visitor-count scale into 5 readable bins. Umami country data
|
||||||
|
* is heavily skewed (top country may have 900+, most have 0–5), so a
|
||||||
|
* linear gradient looks visually flat. The buckets are derived from the
|
||||||
|
* observed max so the highest bin is always saturated.
|
||||||
|
*/
|
||||||
|
function bucketizeMax(max: number): Array<{ min?: number; max?: number; label: string }> {
|
||||||
|
if (max <= 0) return [{ min: 0, max: 0, label: '0' }];
|
||||||
|
const step = Math.max(1, Math.ceil(max / 5));
|
||||||
|
return [
|
||||||
|
{ min: 0, max: 0, label: '0' },
|
||||||
|
{ min: 1, max: step, label: `1–${step.toLocaleString()}` },
|
||||||
|
{
|
||||||
|
min: step + 1,
|
||||||
|
max: step * 2,
|
||||||
|
label: `${(step + 1).toLocaleString()}–${(step * 2).toLocaleString()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: step * 2 + 1,
|
||||||
|
max: step * 3,
|
||||||
|
label: `${(step * 2 + 1).toLocaleString()}–${(step * 3).toLocaleString()}`,
|
||||||
|
},
|
||||||
|
{ min: step * 3 + 1, label: `${(step * 3 + 1).toLocaleString()}+` },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -11,9 +11,12 @@
|
|||||||
* port - points the operator at /admin/website-analytics to set creds.
|
* port - points the operator at /admin/website-analytics to set creds.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Globe, Settings, ExternalLink } from 'lucide-react';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -21,22 +24,50 @@ import { KPITile } from '@/components/ui/kpi-tile';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||||
import { type DateRange } from '@/lib/analytics/range';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import {
|
import {
|
||||||
useUmamiActive,
|
useUmamiActive,
|
||||||
|
useUmamiAllCountries,
|
||||||
useUmamiPageviews,
|
useUmamiPageviews,
|
||||||
useUmamiStats,
|
useUmamiStats,
|
||||||
|
useUmamiTopBrowsers,
|
||||||
useUmamiTopCountries,
|
useUmamiTopCountries,
|
||||||
|
useUmamiTopDevices,
|
||||||
|
useUmamiTopOS,
|
||||||
useUmamiTopPages,
|
useUmamiTopPages,
|
||||||
useUmamiTopReferrers,
|
useUmamiTopReferrers,
|
||||||
|
useUmamiWebsiteInfo,
|
||||||
} from './use-website-analytics';
|
} from './use-website-analytics';
|
||||||
import { PageviewsChart } from './pageviews-chart';
|
import { PageviewsChart } from './pageviews-chart';
|
||||||
|
import { RealtimePanel } from './realtime-panel';
|
||||||
|
import { SessionsList } from './sessions-list';
|
||||||
import { TopList } from './top-list';
|
import { TopList } from './top-list';
|
||||||
|
import { VisitorWorldMap } from './visitor-world-map';
|
||||||
|
import { WeeklyHeatmap } from './weekly-heatmap';
|
||||||
|
|
||||||
export function WebsiteAnalyticsShell() {
|
export function WebsiteAnalyticsShell() {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
// Hydrate range from URL so /website-analytics?range=90d (or
|
||||||
|
// ?range=custom&from=…&to=…) survives refreshes and round-trips from
|
||||||
|
// the "View all" detail pages.
|
||||||
|
const [range, setRange] = useState<DateRange>(() => parseRangeFromQuery(searchParams));
|
||||||
|
|
||||||
|
function handleRangeChange(next: DateRange) {
|
||||||
|
setRange(next);
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (isCustomRange(next)) {
|
||||||
|
sp.set('range', 'custom');
|
||||||
|
sp.set('from', next.from);
|
||||||
|
sp.set('to', next.to);
|
||||||
|
} else {
|
||||||
|
sp.set('range', next);
|
||||||
|
}
|
||||||
|
router.replace(`/${portSlug}/website-analytics?${sp.toString()}` as never);
|
||||||
|
}
|
||||||
|
|
||||||
const stats = useUmamiStats(range);
|
const stats = useUmamiStats(range);
|
||||||
const pageviews = useUmamiPageviews(range);
|
const pageviews = useUmamiPageviews(range);
|
||||||
@@ -44,6 +75,14 @@ export function WebsiteAnalyticsShell() {
|
|||||||
const topPages = useUmamiTopPages(range);
|
const topPages = useUmamiTopPages(range);
|
||||||
const topReferrers = useUmamiTopReferrers(range);
|
const topReferrers = useUmamiTopReferrers(range);
|
||||||
const topCountries = useUmamiTopCountries(range);
|
const topCountries = useUmamiTopCountries(range);
|
||||||
|
const allCountries = useUmamiAllCountries(range);
|
||||||
|
const topBrowsers = useUmamiTopBrowsers(range);
|
||||||
|
const topOS = useUmamiTopOS(range);
|
||||||
|
const topDevices = useUmamiTopDevices(range);
|
||||||
|
const websiteInfo = useUmamiWebsiteInfo(range);
|
||||||
|
// Prefer the live domain from the connected website; fall back to a
|
||||||
|
// generic eyebrow until the metadata request resolves.
|
||||||
|
const eyebrow = websiteInfo.data?.data?.domain || websiteInfo.data?.data?.name || 'Marketing';
|
||||||
|
|
||||||
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
|
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
|
||||||
// React Query doesn't infinite-retry — that retry loop saturated the
|
// React Query doesn't infinite-retry — that retry loop saturated the
|
||||||
@@ -55,54 +94,93 @@ export function WebsiteAnalyticsShell() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Website analytics"
|
title="Website analytics"
|
||||||
eyebrow="Marketing"
|
eyebrow={eyebrow}
|
||||||
description="Live data from Umami - site traffic, top pages, referrers, and audience geography."
|
description="Site traffic, top pages, referrers, and audience geography."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{notConfigured ? (
|
{notConfigured ? (
|
||||||
<NotConfiguredEmptyState portSlug={portSlug} />
|
<NotConfiguredEmptyState portSlug={portSlug} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Live indicator + KPI tiles */}
|
{/* Realtime panel — collapsible "what's happening RIGHT NOW"
|
||||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-5">
|
strip at the very top. Polling only fires while expanded. */}
|
||||||
|
<RealtimePanel />
|
||||||
|
|
||||||
|
{/* Live indicator + KPI tiles — mirrors Umami's overview row. */}
|
||||||
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||||
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
||||||
<KpiPair
|
|
||||||
label="Pageviews"
|
|
||||||
loading={stats.isLoading}
|
|
||||||
value={stats.data?.data?.pageviews?.value}
|
|
||||||
prev={stats.data?.data?.pageviews?.prev}
|
|
||||||
accent="brand"
|
|
||||||
/>
|
|
||||||
<KpiPair
|
<KpiPair
|
||||||
label="Visitors"
|
label="Visitors"
|
||||||
loading={stats.isLoading}
|
loading={stats.isLoading}
|
||||||
value={stats.data?.data?.visitors?.value}
|
value={stats.data?.data?.visitors}
|
||||||
prev={stats.data?.data?.visitors?.prev}
|
prev={stats.data?.data?.comparison?.visitors}
|
||||||
accent="teal"
|
accent="teal"
|
||||||
|
tooltip="Unique people who visited the site at least once. Counted by anonymous device fingerprint — one person across two devices counts as two."
|
||||||
/>
|
/>
|
||||||
<KpiPair
|
<KpiPair
|
||||||
label="Visits"
|
label="Visits"
|
||||||
loading={stats.isLoading}
|
loading={stats.isLoading}
|
||||||
value={stats.data?.data?.visits?.value}
|
value={stats.data?.data?.visits}
|
||||||
prev={stats.data?.data?.visits?.prev}
|
prev={stats.data?.data?.comparison?.visits}
|
||||||
accent="success"
|
accent="success"
|
||||||
|
tooltip="Distinct browsing sessions. A new visit starts when a visitor returns after 30 minutes of inactivity. One person can rack up multiple visits."
|
||||||
/>
|
/>
|
||||||
<KpiPair
|
<KpiPair
|
||||||
label="Bounces"
|
label="Pageviews"
|
||||||
loading={stats.isLoading}
|
loading={stats.isLoading}
|
||||||
value={stats.data?.data?.bounces?.value}
|
value={stats.data?.data?.pageviews}
|
||||||
prev={stats.data?.data?.bounces?.prev}
|
prev={stats.data?.data?.comparison?.pageviews}
|
||||||
accent="purple"
|
accent="brand"
|
||||||
invertDelta
|
tooltip="Total page loads, including refreshes and back-navigation. One visit browsing five pages = 5 pageviews."
|
||||||
|
/>
|
||||||
|
<BounceRateTile
|
||||||
|
loading={stats.isLoading}
|
||||||
|
bounces={stats.data?.data?.bounces}
|
||||||
|
visits={stats.data?.data?.visits}
|
||||||
|
prevBounces={stats.data?.data?.comparison?.bounces}
|
||||||
|
prevVisits={stats.data?.data?.comparison?.visits}
|
||||||
|
/>
|
||||||
|
<VisitDurationTile
|
||||||
|
loading={stats.isLoading}
|
||||||
|
totaltime={stats.data?.data?.totaltime}
|
||||||
|
visits={stats.data?.data?.visits}
|
||||||
|
prevTotaltime={stats.data?.data?.comparison?.totaltime}
|
||||||
|
prevVisits={stats.data?.data?.comparison?.visits}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pageviews trend */}
|
{/* Views (pageviews + sessions) trend */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Pageviews trend</CardTitle>
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
|
Views
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
type="button"
|
||||||
|
aria-label="What's the difference between pageviews and sessions?"
|
||||||
|
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||||
|
>
|
||||||
|
<Info className="size-3.5" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||||
|
<p className="font-semibold text-foreground">Pageviews vs Sessions</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
<strong>Pageviews</strong> = total page hits, including refreshes and
|
||||||
|
back-button navigation. One visitor browsing five pages = 5 pageviews.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
<strong>Sessions</strong> = distinct visitor sessions. The same person
|
||||||
|
browsing five pages in one sitting still counts as 1 session.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Pages-per-session (pageviews ÷ sessions) is a rough engagement signal — higher
|
||||||
|
means people are exploring deeper.
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{pageviews.isLoading ? (
|
{pageviews.isLoading ? (
|
||||||
@@ -113,25 +191,84 @@ export function WebsiteAnalyticsShell() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Top-N tables */}
|
{/* Audience: pages / referrers / countries */}
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
||||||
<TopList
|
<TopList
|
||||||
title="Top pages"
|
title="Top pages"
|
||||||
loading={topPages.isLoading}
|
loading={topPages.isLoading}
|
||||||
rows={topPages.data?.data ?? null}
|
viewAllHref={detailHref(portSlug, 'pages', range)}
|
||||||
|
rows={
|
||||||
|
topPages.data?.data
|
||||||
|
? topPages.data.data.map((row) => ({
|
||||||
|
...row,
|
||||||
|
x: row.x === '/' ? 'Homepage' : row.x,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TopList
|
<TopList
|
||||||
title="Top referrers"
|
title="Top referrers"
|
||||||
loading={topReferrers.isLoading}
|
loading={topReferrers.isLoading}
|
||||||
|
viewAllHref={detailHref(portSlug, 'referrers', range)}
|
||||||
rows={topReferrers.data?.data ?? null}
|
rows={topReferrers.data?.data ?? null}
|
||||||
defaultLabel="(direct)"
|
defaultLabel="(direct)"
|
||||||
/>
|
/>
|
||||||
<TopList
|
<TopList
|
||||||
title="Top countries"
|
title="Top countries"
|
||||||
loading={topCountries.isLoading}
|
loading={topCountries.isLoading}
|
||||||
rows={topCountries.data?.data ?? null}
|
viewAllHref={detailHref(portSlug, 'countries', range)}
|
||||||
|
rows={
|
||||||
|
topCountries.data?.data
|
||||||
|
? topCountries.data.data.map((row) => ({
|
||||||
|
...row,
|
||||||
|
x: getCountryName(row.x, 'en'),
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tech breakdown: browsers / OS / devices */}
|
||||||
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
||||||
|
<TopList
|
||||||
|
title="Top browsers"
|
||||||
|
loading={topBrowsers.isLoading}
|
||||||
|
viewAllHref={detailHref(portSlug, 'browsers', range)}
|
||||||
|
rows={topBrowsers.data?.data ?? null}
|
||||||
|
/>
|
||||||
|
<TopList
|
||||||
|
title="Top operating systems"
|
||||||
|
loading={topOS.isLoading}
|
||||||
|
viewAllHref={detailHref(portSlug, 'os', range)}
|
||||||
|
rows={topOS.data?.data ?? null}
|
||||||
|
/>
|
||||||
|
<TopList
|
||||||
|
title="Top devices"
|
||||||
|
loading={topDevices.isLoading}
|
||||||
|
viewAllHref={detailHref(portSlug, 'devices', range)}
|
||||||
|
rows={
|
||||||
|
topDevices.data?.data
|
||||||
|
? topDevices.data.data.map((row) => ({
|
||||||
|
...row,
|
||||||
|
x: row.x === '' ? 'Unknown' : row.x.charAt(0).toUpperCase() + row.x.slice(1),
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Engagement heatmap — full-width so the 7×24 grid has room
|
||||||
|
to breathe and cells are large enough to hover comfortably. */}
|
||||||
|
<WeeklyHeatmap range={range} />
|
||||||
|
|
||||||
|
{/* Recent sessions */}
|
||||||
|
<SessionsList range={range} />
|
||||||
|
|
||||||
|
{/* World heatmap — visitor counts per country (full-width, bottom of page) */}
|
||||||
|
<VisitorWorldMap
|
||||||
|
rows={allCountries.data?.data ?? null}
|
||||||
|
loading={allCountries.isLoading}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,8 +279,26 @@ function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: bool
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm relative overflow-hidden">
|
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm relative overflow-hidden">
|
||||||
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
|
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
|
||||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
<div className="flex items-center gap-1.5">
|
||||||
Active right now
|
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||||
|
Active right now
|
||||||
|
</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
type="button"
|
||||||
|
aria-label="How is this counted?"
|
||||||
|
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||||
|
>
|
||||||
|
<Info className="size-3" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-72 text-xs leading-relaxed">
|
||||||
|
<p className="font-semibold text-foreground">Live visitor count</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Visitors active in the last <strong>5 minutes</strong>, as reported by your analytics
|
||||||
|
backend. This card auto-refreshes every <strong>30 seconds</strong>.
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -169,6 +324,7 @@ function KpiPair({
|
|||||||
accent,
|
accent,
|
||||||
loading,
|
loading,
|
||||||
invertDelta = false,
|
invertDelta = false,
|
||||||
|
tooltip,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
@@ -178,6 +334,7 @@ function KpiPair({
|
|||||||
/** For metrics where lower is better (bounces). Flip the sign so green
|
/** For metrics where lower is better (bounces). Flip the sign so green
|
||||||
* still means "good" in the UI. */
|
* still means "good" in the UI. */
|
||||||
invertDelta?: boolean;
|
invertDelta?: boolean;
|
||||||
|
tooltip?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -194,7 +351,141 @@ function KpiPair({
|
|||||||
delta = Math.round(((v - p) / p) * 100);
|
delta = Math.round(((v - p) / p) * 100);
|
||||||
if (invertDelta) delta = -delta;
|
if (invertDelta) delta = -delta;
|
||||||
}
|
}
|
||||||
return <KPITile title={label} value={v.toLocaleString()} accent={accent} delta={delta} />;
|
return (
|
||||||
|
<KPITile
|
||||||
|
title={label}
|
||||||
|
value={v.toLocaleString()}
|
||||||
|
accent={accent}
|
||||||
|
delta={delta}
|
||||||
|
deltaSuffix="%"
|
||||||
|
tooltip={tooltip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounce rate = bounces / visits × 100. Lower is better, so the delta is
|
||||||
|
* inverted relative to the raw bounce count (a drop in bounce rate is
|
||||||
|
* "good" → green up-arrow).
|
||||||
|
*/
|
||||||
|
function BounceRateTile({
|
||||||
|
bounces,
|
||||||
|
visits,
|
||||||
|
prevBounces,
|
||||||
|
prevVisits,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
bounces: number | undefined;
|
||||||
|
visits: number | undefined;
|
||||||
|
prevBounces: number | undefined;
|
||||||
|
prevVisits: number | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="mt-2 h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rate = (visits ?? 0) > 0 ? ((bounces ?? 0) / (visits ?? 1)) * 100 : 0;
|
||||||
|
const prevRate = (prevVisits ?? 0) > 0 ? ((prevBounces ?? 0) / (prevVisits ?? 1)) * 100 : 0;
|
||||||
|
let delta: number | undefined;
|
||||||
|
if (prevRate > 0) {
|
||||||
|
// Show the raw percentage-point change (positive = bounce went up).
|
||||||
|
// KPITile's lowerIsBetter flips the colour so a drop renders green.
|
||||||
|
delta = Math.round(rate - prevRate);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<KPITile
|
||||||
|
title="Bounce rate"
|
||||||
|
value={`${rate.toFixed(1)}%`}
|
||||||
|
accent="purple"
|
||||||
|
delta={delta}
|
||||||
|
deltaSuffix="%"
|
||||||
|
lowerIsBetter
|
||||||
|
tooltip="Share of visits that ended without a second pageview — i.e. someone landed, didn't click anything, and left. Lower is generally better."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avg visit duration = totaltime / visits (Umami returns totaltime in
|
||||||
|
* seconds across all visits in the range). Formatted as `Xm Ys`.
|
||||||
|
*/
|
||||||
|
function VisitDurationTile({
|
||||||
|
totaltime,
|
||||||
|
visits,
|
||||||
|
prevTotaltime,
|
||||||
|
prevVisits,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
totaltime: number | undefined;
|
||||||
|
visits: number | undefined;
|
||||||
|
prevTotaltime: number | undefined;
|
||||||
|
prevVisits: number | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="mt-2 h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const secs = (visits ?? 0) > 0 ? (totaltime ?? 0) / (visits ?? 1) : 0;
|
||||||
|
const prevSecs = (prevVisits ?? 0) > 0 ? (prevTotaltime ?? 0) / (prevVisits ?? 1) : 0;
|
||||||
|
let delta: number | undefined;
|
||||||
|
if (prevSecs > 0) {
|
||||||
|
delta = Math.round(((secs - prevSecs) / prevSecs) * 100);
|
||||||
|
}
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.round(secs % 60);
|
||||||
|
const display = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||||
|
return (
|
||||||
|
<KPITile
|
||||||
|
title="Visit duration"
|
||||||
|
value={display}
|
||||||
|
accent="mint"
|
||||||
|
delta={delta}
|
||||||
|
deltaSuffix="%"
|
||||||
|
tooltip="Average time a visitor spent on the site per visit. Longer usually means deeper engagement, though it can also mean people got stuck."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the range from the current URL search params, defaulting to 30d. */
|
||||||
|
function parseRangeFromQuery(searchParams: URLSearchParams | null): DateRange {
|
||||||
|
const rawRange = searchParams?.get('range') ?? '30d';
|
||||||
|
if (rawRange === 'custom') {
|
||||||
|
const from = searchParams?.get('from');
|
||||||
|
const to = searchParams?.get('to');
|
||||||
|
if (from && to) return { kind: 'custom', from, to };
|
||||||
|
return '30d';
|
||||||
|
}
|
||||||
|
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
|
||||||
|
return rawRange;
|
||||||
|
}
|
||||||
|
return '30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the "View all" href for a metric, carrying the current range. */
|
||||||
|
function detailHref(
|
||||||
|
portSlug: string | null,
|
||||||
|
metric: 'pages' | 'referrers' | 'countries' | 'browsers' | 'os' | 'devices',
|
||||||
|
range: DateRange,
|
||||||
|
): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (isCustomRange(range)) {
|
||||||
|
sp.set('range', 'custom');
|
||||||
|
sp.set('from', range.from);
|
||||||
|
sp.set('to', range.to);
|
||||||
|
} else {
|
||||||
|
sp.set('range', range);
|
||||||
|
}
|
||||||
|
return `/${portSlug}/website-analytics/${metric}?${sp.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {
|
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {
|
||||||
|
|||||||
168
src/components/website-analytics/weekly-heatmap.tsx
Normal file
168
src/components/website-analytics/weekly-heatmap.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour-of-week engagement heatmap. Driven by Umami's
|
||||||
|
* `/api/websites/:id/sessions/weekly` endpoint which returns a 7×24
|
||||||
|
* nested array of session counts (rows Sun…Sat, cols 0…23 UTC).
|
||||||
|
*
|
||||||
|
* Visual: a 7-row × 24-col grid of cells, with cell colour intensity
|
||||||
|
* scaled to the max value across the whole grid. Hover any cell for a
|
||||||
|
* floating tooltip showing the exact day/hour/count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useUmamiSessionsWeekly } from './use-website-analytics';
|
||||||
|
import { type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
interface HoverInfo {
|
||||||
|
dayIdx: number;
|
||||||
|
hour: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeeklyHeatmap({ range }: Props) {
|
||||||
|
const query = useUmamiSessionsWeekly(range);
|
||||||
|
const [hover, setHover] = useState<HoverInfo | null>(null);
|
||||||
|
const grid = query.data?.data ?? null;
|
||||||
|
const max = useMemo(() => {
|
||||||
|
if (!grid) return 0;
|
||||||
|
let m = 0;
|
||||||
|
for (const row of grid) for (const v of row) if (v > m) m = v;
|
||||||
|
return m;
|
||||||
|
}, [grid]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
|
Engagement heatmap
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
type="button"
|
||||||
|
aria-label="How to read the engagement heatmap"
|
||||||
|
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||||
|
>
|
||||||
|
<Info className="size-3.5" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||||
|
<p className="font-semibold text-foreground">When is your audience active?</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Each cell is one hour of one day — the columns are{' '}
|
||||||
|
<strong>hours of the day in UTC</strong> (0 = midnight, 23 = 11 PM) and the rows are
|
||||||
|
days of the week. Darker blue means more sessions started during that hour across
|
||||||
|
the whole selected period. Hover any cell for the exact session count.
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<Skeleton className="h-[220px] w-full" />
|
||||||
|
) : !grid || max === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No session activity in this range.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative" onMouseLeave={() => setHover(null)}>
|
||||||
|
{/* Hour-axis header */}
|
||||||
|
<div className="flex pl-12 text-[10px] text-muted-foreground">
|
||||||
|
{Array.from({ length: 24 }).map((_, h) => (
|
||||||
|
<div key={h} className="flex-1 text-center">
|
||||||
|
{h % 2 === 0 ? h : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="mt-1">
|
||||||
|
{grid.map((row, dIdx) => (
|
||||||
|
<div key={dIdx} className="flex items-center">
|
||||||
|
<div className="w-12 pr-2 text-right text-[11px] text-muted-foreground">
|
||||||
|
{DAYS_SHORT[dIdx]}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 gap-px">
|
||||||
|
{row.map((v, h) => {
|
||||||
|
const intensity = v / max;
|
||||||
|
const isHovered = hover?.dayIdx === dIdx && hover?.hour === h;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
onMouseEnter={() => setHover({ dayIdx: dIdx, hour: h, count: v })}
|
||||||
|
className="aspect-square min-w-0 flex-1 rounded-sm transition-all cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
v === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * intensity})`,
|
||||||
|
outline: isHovered ? '2px solid #f59e0b' : 'none',
|
||||||
|
outlineOffset: '-1px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Hour-axis footer (mirrors header so wide cards read symmetrically) */}
|
||||||
|
<div className="mt-1 flex pl-12 text-[10px] text-muted-foreground">
|
||||||
|
{Array.from({ length: 24 }).map((_, h) => (
|
||||||
|
<div key={h} className="flex-1 text-center">
|
||||||
|
{h % 2 === 0 ? `${h}:00` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend + floating value indicator */}
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Hour of day (UTC) — colour intensity scaled to peak ({max.toLocaleString()}{' '}
|
||||||
|
sessions)
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>Less</span>
|
||||||
|
<div className="flex gap-px">
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="size-3 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
i === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * i})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover tooltip — single element re-positioned via the
|
||||||
|
hovered cell's data, much cheaper than mounting 168
|
||||||
|
Radix Tooltips. */}
|
||||||
|
{hover ? (
|
||||||
|
<div className="pointer-events-none absolute top-0 right-0 -mt-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs shadow-md">
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{DAYS_FULL[hover.dayIdx]} {hover.hour}:00–{hover.hour}:59 UTC
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums text-muted-foreground">
|
||||||
|
{hover.count.toLocaleString()} session{hover.count === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/lib/db/migrations/0076_email_open_tracking.sql
Normal file
19
src/lib/db/migrations/0076_email_open_tracking.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Phase 4b — email open tracking via a 1×1 pixel endpoint.
|
||||||
|
-- Adds a per-send open log + cached aggregates on document_sends.
|
||||||
|
|
||||||
|
ALTER TABLE "document_sends"
|
||||||
|
ADD COLUMN IF NOT EXISTS "track_opens" boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS "first_opened_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "open_count" integer NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "document_send_opens" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"port_id" text NOT NULL REFERENCES "ports"("id"),
|
||||||
|
"send_id" text NOT NULL REFERENCES "document_sends"("id") ON DELETE CASCADE,
|
||||||
|
"opened_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"user_agent" text,
|
||||||
|
"referer" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_dso_send" ON "document_send_opens" ("send_id", "opened_at" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_dso_port" ON "document_send_opens" ("port_id", "opened_at" DESC);
|
||||||
36
src/lib/db/migrations/0077_tracked_links.sql
Normal file
36
src/lib/db/migrations/0077_tracked_links.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Phase 4c — tracked redirect links for email click-through tracking.
|
||||||
|
-- A short URL at /q/<slug> redirects to the target and records the
|
||||||
|
-- click against the originating send. Cross-posted to Umami as a
|
||||||
|
-- `link-clicked` event.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "tracked_links" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"port_id" text NOT NULL REFERENCES "ports"("id"),
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"target_url" text NOT NULL,
|
||||||
|
"send_id" text REFERENCES "document_sends"("id") ON DELETE SET NULL,
|
||||||
|
"click_count" integer NOT NULL DEFAULT 0,
|
||||||
|
"first_clicked_at" timestamptz,
|
||||||
|
"last_clicked_at" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"created_by_user_id" text REFERENCES "user"("id") ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Slugs are scoped to a port; an admin can rotate them per-port. Global
|
||||||
|
-- uniqueness isn't required because /q/<slug> is gated by tenancy in
|
||||||
|
-- the route handler.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_tracked_links_slug" ON "tracked_links" ("slug");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_tracked_links_send" ON "tracked_links" ("send_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_tracked_links_port" ON "tracked_links" ("port_id", "created_at" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "tracked_link_clicks" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"tracked_link_id" text NOT NULL REFERENCES "tracked_links"("id") ON DELETE CASCADE,
|
||||||
|
"port_id" text NOT NULL REFERENCES "ports"("id"),
|
||||||
|
"clicked_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"user_agent" text,
|
||||||
|
"referer" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_tlc_link" ON "tracked_link_clicks" ("tracked_link_id", "clicked_at" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_tlc_port" ON "tracked_link_clicks" ("port_id", "clicked_at" DESC);
|
||||||
@@ -76,3 +76,4 @@ export * from './pipeline';
|
|||||||
|
|
||||||
// Relations (must come last - references all tables)
|
// Relations (must come last - references all tables)
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
export * from './tracked-links';
|
||||||
|
|||||||
71
src/lib/db/schema/tracked-links.ts
Normal file
71
src/lib/db/schema/tracked-links.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { pgTable, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { user } from './users';
|
||||||
|
import { documentSends } from './brochures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4c — tracked redirect links. A short URL `/q/<slug>` records a
|
||||||
|
* click and 302s the recipient on to `targetUrl`. The matching click
|
||||||
|
* row is fire-and-forget so the redirect stays snappy; an aggregate
|
||||||
|
* `clickCount` on the parent row keeps "was clicked at all" queries
|
||||||
|
* cheap.
|
||||||
|
*
|
||||||
|
* `sendId` is the optional link back to the originating outbound email
|
||||||
|
* — set when the link is minted via the email-composer flow so reps can
|
||||||
|
* see per-email click-throughs. Manual one-off short links leave it null.
|
||||||
|
*/
|
||||||
|
export const trackedLinks = pgTable(
|
||||||
|
'tracked_links',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
targetUrl: text('target_url').notNull(),
|
||||||
|
sendId: text('send_id').references(() => documentSends.id, { onDelete: 'set null' }),
|
||||||
|
clickCount: integer('click_count').notNull().default(0),
|
||||||
|
firstClickedAt: timestamp('first_clicked_at', { withTimezone: true }),
|
||||||
|
lastClickedAt: timestamp('last_clicked_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdByUserId: text('created_by_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('uniq_tracked_links_slug').on(t.slug),
|
||||||
|
index('idx_tracked_links_send').on(t.sendId),
|
||||||
|
index('idx_tracked_links_port').on(t.portId, t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Per-click log. Apple Mail privacy proxy will pre-fetch tracked link
|
||||||
|
* URLs the same way it does pixels — clicks from iOS users are
|
||||||
|
* over-counted. Standard email-tracking caveats apply. */
|
||||||
|
export const trackedLinkClicks = pgTable(
|
||||||
|
'tracked_link_clicks',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
trackedLinkId: text('tracked_link_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => trackedLinks.id, { onDelete: 'cascade' }),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
clickedAt: timestamp('clicked_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
referer: text('referer'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_tlc_link').on(t.trackedLinkId, t.clickedAt),
|
||||||
|
index('idx_tlc_port').on(t.portId, t.clickedAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TrackedLink = typeof trackedLinks.$inferSelect;
|
||||||
|
export type NewTrackedLink = typeof trackedLinks.$inferInsert;
|
||||||
|
export type TrackedLinkClick = typeof trackedLinkClicks.$inferSelect;
|
||||||
|
export type NewTrackedLinkClick = typeof trackedLinkClicks.$inferInsert;
|
||||||
46
src/lib/email/tracking-pixel.ts
Normal file
46
src/lib/email/tracking-pixel.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Open-tracking pixel injector (Phase 4b). Appends a 1×1 transparent
|
||||||
|
* image pointing at /api/public/email-pixel/[sendId] to outbound HTML
|
||||||
|
* emails. The pixel endpoint records the open + cross-posts the event
|
||||||
|
* to Umami.
|
||||||
|
*
|
||||||
|
* Sites that want to opt out of tracking simply don't call this helper.
|
||||||
|
* The pixel URL is unguessable per-send (UUID), but a `track_opens=false`
|
||||||
|
* row in `document_sends` makes the endpoint a no-op even if someone
|
||||||
|
* does guess one.
|
||||||
|
*
|
||||||
|
* Privacy: respects EMAIL_REDIRECT_TO (no pixel injected when dev
|
||||||
|
* redirect is active) so a re-routed message doesn't fire a fake open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
|
||||||
|
interface InjectOptions {
|
||||||
|
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
|
||||||
|
* Required so the pixel link is absolute — relative URLs break in
|
||||||
|
* email clients. */
|
||||||
|
appBaseUrl: string;
|
||||||
|
/** UUID of the row in `document_sends`. */
|
||||||
|
sendId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a 1×1 tracking pixel just before `</body>` (or at the end of the
|
||||||
|
* document if no `</body>` is present). Returns the HTML unchanged when
|
||||||
|
* EMAIL_REDIRECT_TO is set so dev-mode re-routing doesn't generate
|
||||||
|
* misleading open events.
|
||||||
|
*/
|
||||||
|
export function injectTrackingPixel(html: string, opts: InjectOptions): string {
|
||||||
|
if (env.EMAIL_REDIRECT_TO) return html;
|
||||||
|
|
||||||
|
const base = opts.appBaseUrl.replace(/\/$/, '');
|
||||||
|
const pixelUrl = `${base}/api/public/email-pixel/${opts.sendId}`;
|
||||||
|
const pixelTag =
|
||||||
|
`<img src="${pixelUrl}" width="1" height="1" alt="" ` +
|
||||||
|
`style="display:block;border:0;margin:0;padding:0" />`;
|
||||||
|
|
||||||
|
if (html.includes('</body>')) {
|
||||||
|
return html.replace('</body>', `${pixelTag}</body>`);
|
||||||
|
}
|
||||||
|
return html + pixelTag;
|
||||||
|
}
|
||||||
70
src/lib/services/tracked-links.service.ts
Normal file
70
src/lib/services/tracked-links.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { trackedLinks, type NewTrackedLink } from '@/lib/db/schema/tracked-links';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4c — service-layer helpers for tracked redirect links. Use
|
||||||
|
* `createTrackedLink` from any email-composer flow to wrap an outbound
|
||||||
|
* URL in a `/q/<slug>` short-link that records click-throughs.
|
||||||
|
*
|
||||||
|
* Slug format: random URL-safe ID. Short enough not to overwhelm an
|
||||||
|
* inbox preview pane but long enough that collision probability is
|
||||||
|
* negligible across the lifetime of the system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function generateSlug(): string {
|
||||||
|
// 8 random bytes → 11-char base64url string. Collision probability
|
||||||
|
// across 1M links: ~1e-7. The DB unique index is the backstop.
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTrackedLinkInput {
|
||||||
|
portId: string;
|
||||||
|
targetUrl: string;
|
||||||
|
/** Optional FK to `document_sends.id` so per-email click-throughs are
|
||||||
|
* attributable. Leave null for one-off short links. */
|
||||||
|
sendId?: string;
|
||||||
|
createdByUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrackedLink(input: CreateTrackedLinkInput) {
|
||||||
|
// Retry on slug collision (extremely rare). Three attempts is more
|
||||||
|
// than enough — at our slug entropy a single collision in 1M links
|
||||||
|
// would be a once-per-century event.
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const slug = generateSlug();
|
||||||
|
try {
|
||||||
|
const values: NewTrackedLink = {
|
||||||
|
portId: input.portId,
|
||||||
|
slug,
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
...(input.sendId ? { sendId: input.sendId } : {}),
|
||||||
|
...(input.createdByUserId ? { createdByUserId: input.createdByUserId } : {}),
|
||||||
|
};
|
||||||
|
const [row] = await db.insert(trackedLinks).values(values).returning();
|
||||||
|
return row!;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('uniq_tracked_links_slug') && attempt < 2) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Failed to mint a unique tracked-link slug after 3 attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the public-facing tracked URL for an existing record. */
|
||||||
|
export function buildTrackedUrl(slug: string): string {
|
||||||
|
const base = env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '');
|
||||||
|
return `${base}/q/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up click stats for a single tracked link. */
|
||||||
|
export async function getTrackedLink(id: string) {
|
||||||
|
return db.query.trackedLinks.findFirst({ where: eq(trackedLinks.id, id) });
|
||||||
|
}
|
||||||
@@ -211,12 +211,30 @@ function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
|
|||||||
|
|
||||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats response from `/api/websites/:id/stats` on Umami v2.x / v3.x.
|
||||||
|
*
|
||||||
|
* Each top-level metric is a plain number for the requested range; the
|
||||||
|
* `comparison` block carries the equivalent values for the previous
|
||||||
|
* window of the same length (so a 30-day range comes back with the prior
|
||||||
|
* 30 days as `comparison.*`). Verified empirically against Umami v3.1.0
|
||||||
|
* — earlier internal types modelled this as `{value, prev}` per metric,
|
||||||
|
* which matched neither v2 nor v3 and caused the dashboard tile to read
|
||||||
|
* `pageviews.value` as undefined and render 0.
|
||||||
|
*/
|
||||||
export interface UmamiStats {
|
export interface UmamiStats {
|
||||||
pageviews: { value: number; prev: number };
|
pageviews: number;
|
||||||
visitors: { value: number; prev: number };
|
visitors: number;
|
||||||
visits: { value: number; prev: number };
|
visits: number;
|
||||||
bounces: { value: number; prev: number };
|
bounces: number;
|
||||||
totaltime: { value: number; prev: number };
|
totaltime: number;
|
||||||
|
comparison?: {
|
||||||
|
pageviews: number;
|
||||||
|
visitors: number;
|
||||||
|
visits: number;
|
||||||
|
bounces: number;
|
||||||
|
totaltime: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
|
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
|
||||||
@@ -227,9 +245,15 @@ export async function getStats(portId: string, range: DateRange): Promise<UmamiS
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pageviews time-series response. Umami v3 returns the `pageviews` array
|
||||||
|
* unconditionally; the `sessions` array only appears when the request
|
||||||
|
* includes a `compare` directive (omitted today). The optional field
|
||||||
|
* keeps the type honest so consumers don't blindly read `.sessions[0]`.
|
||||||
|
*/
|
||||||
export interface UmamiPageviewsSeries {
|
export interface UmamiPageviewsSeries {
|
||||||
pageviews: Array<{ x: string; y: number }>;
|
pageviews: Array<{ x: string; y: number }>;
|
||||||
sessions: Array<{ x: string; y: number }>;
|
sessions?: Array<{ x: string; y: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPageviewsSeries(
|
export async function getPageviewsSeries(
|
||||||
@@ -245,14 +269,25 @@ export async function getPageviewsSeries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x.
|
||||||
|
* `path` replaces the old `url` value — sending `type=url` against a v3
|
||||||
|
* instance returns 400. The full Umami enum also includes `entry|exit|
|
||||||
|
* title|query|region|city|language|screen|hostname|tag|distinctId`; only
|
||||||
|
* the ones the CRM actually surfaces are listed here.
|
||||||
|
*/
|
||||||
export type UmamiMetricType =
|
export type UmamiMetricType =
|
||||||
| 'url'
|
| 'path'
|
||||||
| 'referrer'
|
| 'referrer'
|
||||||
| 'browser'
|
| 'browser'
|
||||||
| 'os'
|
| 'os'
|
||||||
| 'device'
|
| 'device'
|
||||||
| 'country'
|
| 'country'
|
||||||
| 'event';
|
| 'region'
|
||||||
|
| 'city'
|
||||||
|
| 'event'
|
||||||
|
| 'title'
|
||||||
|
| 'query';
|
||||||
|
|
||||||
export interface UmamiMetricRow {
|
export interface UmamiMetricRow {
|
||||||
x: string;
|
x: string;
|
||||||
@@ -284,6 +319,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
|
|||||||
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
|
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Website-level metadata (name + domain) so the analytics page can show
|
||||||
|
* which site it's reporting on without the operator having to hard-code
|
||||||
|
* the domain in system_settings. */
|
||||||
|
export interface UmamiWebsiteInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsiteInfo(portId: string): Promise<UmamiWebsiteInfo | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
const res = await umamiFetch<{ id?: string; name?: string; domain?: string }>(
|
||||||
|
config,
|
||||||
|
`/api/websites/${config.websiteId}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: res.id ?? config.websiteId,
|
||||||
|
name: res.name ?? res.domain ?? 'Website',
|
||||||
|
domain: res.domain ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
|
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
|
||||||
* authenticated endpoint that proves both auth + websiteId are good.
|
* authenticated endpoint that proves both auth + websiteId are good.
|
||||||
@@ -314,3 +373,333 @@ export async function testConnection(
|
|||||||
return { ok: false, error: message };
|
return { ok: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Realtime panel ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `/api/realtime/:id` is the richer alternative to `/active` — returns
|
||||||
|
// totals, top URLs being viewed right now, top countries, a 30-min
|
||||||
|
// time-series and a recent-event stream. Used by the realtime dashboard.
|
||||||
|
|
||||||
|
export interface UmamiRealtime {
|
||||||
|
urls: Record<string, number>;
|
||||||
|
countries: Record<string, number>;
|
||||||
|
events: Array<{
|
||||||
|
__type: string;
|
||||||
|
os?: string;
|
||||||
|
device?: string;
|
||||||
|
country?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
eventName?: string;
|
||||||
|
browser?: string;
|
||||||
|
createdAt: string;
|
||||||
|
urlPath?: string;
|
||||||
|
referrerDomain?: string;
|
||||||
|
}>;
|
||||||
|
series: {
|
||||||
|
views: Array<{ x: string; y: number }>;
|
||||||
|
visitors: Array<{ x: string; y: number }>;
|
||||||
|
};
|
||||||
|
referrers: Record<string, number>;
|
||||||
|
totals: {
|
||||||
|
visitors: number;
|
||||||
|
views: number;
|
||||||
|
events: number;
|
||||||
|
countries: number;
|
||||||
|
};
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRealtime(portId: string): Promise<UmamiRealtime | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
// 30-minute window matches Umami's own realtime page default.
|
||||||
|
const startAt = Date.now() - 30 * 60 * 1000;
|
||||||
|
return umamiFetch<UmamiRealtime>(config, `/api/realtime/${config.websiteId}`, {
|
||||||
|
startAt,
|
||||||
|
endAt: Date.now(),
|
||||||
|
timezone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sessions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UmamiSession {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
hostname: string;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
device: string;
|
||||||
|
screen: string;
|
||||||
|
language: string;
|
||||||
|
country: string;
|
||||||
|
subdivision1?: string;
|
||||||
|
city?: string;
|
||||||
|
firstAt: string;
|
||||||
|
lastAt: string;
|
||||||
|
visits: number;
|
||||||
|
views: number;
|
||||||
|
events: number;
|
||||||
|
totaltime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UmamiSessionsPage {
|
||||||
|
data: UmamiSession[];
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessions(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
opts: { page?: number; pageSize?: number; query?: string } = {},
|
||||||
|
): Promise<UmamiSessionsPage | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch<UmamiSessionsPage>(config, `/api/websites/${config.websiteId}/sessions`, {
|
||||||
|
...rangeToParams(range),
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
pageSize: opts.pageSize ?? 25,
|
||||||
|
query: opts.query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(portId: string, sessionId: string): Promise<UmamiSession | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch<UmamiSession>(
|
||||||
|
config,
|
||||||
|
`/api/websites/${config.websiteId}/sessions/${sessionId}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UmamiSessionActivity {
|
||||||
|
eventType: number;
|
||||||
|
urlQuery?: string;
|
||||||
|
urlPath: string;
|
||||||
|
eventName?: string;
|
||||||
|
createdAt: string;
|
||||||
|
referrerDomain?: string;
|
||||||
|
eventId: string;
|
||||||
|
visitId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionActivity(
|
||||||
|
portId: string,
|
||||||
|
sessionId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<UmamiSessionActivity[] | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch<UmamiSessionActivity[]>(
|
||||||
|
config,
|
||||||
|
`/api/websites/${config.websiteId}/sessions/${sessionId}/activity`,
|
||||||
|
{ ...rangeToParams(range) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessions by hour-of-week heatmap — returns a 7×24 nested-array (rows are
|
||||||
|
* days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap
|
||||||
|
* card.
|
||||||
|
*/
|
||||||
|
export async function getSessionsWeekly(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
timezone = 'UTC',
|
||||||
|
): Promise<number[][] | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch<number[][]>(config, `/api/websites/${config.websiteId}/sessions/weekly`, {
|
||||||
|
...rangeToParams(range),
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Events ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Wrappers ready for when the marketing site starts firing `umami.track()`
|
||||||
|
// calls. Until then, every read returns an empty list — wired now so the
|
||||||
|
// UI surface can light up immediately on the day events start arriving.
|
||||||
|
|
||||||
|
export interface UmamiEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
websiteId: string;
|
||||||
|
createdAt: string;
|
||||||
|
urlPath: string;
|
||||||
|
eventName?: string;
|
||||||
|
pageTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvents(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
opts: { page?: number; pageSize?: number } = {},
|
||||||
|
): Promise<{ data: UmamiEvent[]; count: number; page: number; pageSize: number } | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch(config, `/api/websites/${config.websiteId}/events`, {
|
||||||
|
...rangeToParams(range),
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
pageSize: opts.pageSize ?? 25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventsStats(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<{ pageviews: number; visitors: number; events: number } | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch(config, `/api/websites/${config.websiteId}/events/stats`, {
|
||||||
|
...rangeToParams(range),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventsSeries(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
eventName: string,
|
||||||
|
unit: 'hour' | 'day' | 'month' = 'day',
|
||||||
|
): Promise<Array<{ x: string; y: number }> | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
return umamiFetch(config, `/api/websites/${config.websiteId}/events/series`, {
|
||||||
|
...rangeToParams(range),
|
||||||
|
eventName,
|
||||||
|
unit,
|
||||||
|
timezone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reports (POST endpoints) ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reports are POST-only and take a JSON body; build a sibling `umamiPost`
|
||||||
|
// helper that handles auth + error shape the same way as `umamiFetch`.
|
||||||
|
|
||||||
|
async function umamiPost<T>(config: UmamiPortConfig, path: string, body: unknown): Promise<T> {
|
||||||
|
const bearer = await resolveBearer(config);
|
||||||
|
const res = await fetchWithTimeout(`${config.apiUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${bearer}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
|
||||||
|
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
|
||||||
|
internalMessage: `Umami unauthorized: ${res.status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
|
||||||
|
internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UmamiFunnelStep {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
dropoff: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runFunnelReport(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
steps: Array<{ type: 'url' | 'event'; value: string }>,
|
||||||
|
windowHours = 24,
|
||||||
|
): Promise<UmamiFunnelStep[] | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
const { from, to } = rangeToBounds(range);
|
||||||
|
return umamiPost<UmamiFunnelStep[]>(config, `/api/reports/funnel`, {
|
||||||
|
websiteId: config.websiteId,
|
||||||
|
steps,
|
||||||
|
window: windowHours * 3600,
|
||||||
|
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UmamiJourneyStep {
|
||||||
|
items: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runJourneyReport(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
startStep?: string,
|
||||||
|
endStep?: string,
|
||||||
|
stepCount = 5,
|
||||||
|
): Promise<UmamiJourneyStep[] | null> {
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
const { from, to } = rangeToBounds(range);
|
||||||
|
return umamiPost<UmamiJourneyStep[]>(config, `/api/reports/journey`, {
|
||||||
|
websiteId: config.websiteId,
|
||||||
|
startStep,
|
||||||
|
endStep,
|
||||||
|
steps: stepCount,
|
||||||
|
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CRM → Umami event push (Phase 6) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Thin wrapper around `@umami/node` so CRM outcome events land in the same
|
||||||
|
// Umami instance the marketing site reports to. Per-port client instances
|
||||||
|
// are cached so we don't re-instantiate on every event.
|
||||||
|
|
||||||
|
import { Umami } from '@umami/node';
|
||||||
|
|
||||||
|
const trackerByPort = new Map<string, Umami>();
|
||||||
|
|
||||||
|
async function getTracker(portId: string): Promise<Umami | null> {
|
||||||
|
const cached = trackerByPort.get(portId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const config = await loadUmamiConfig(portId);
|
||||||
|
if (!config) return null;
|
||||||
|
const tracker = new Umami({ websiteId: config.websiteId, hostUrl: config.apiUrl });
|
||||||
|
trackerByPort.set(portId, tracker);
|
||||||
|
return tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a CRM-side event back to Umami. Outcome milestones (eoi-sent,
|
||||||
|
* eoi-signed, reservation-paid, contract-signed) flow through here so
|
||||||
|
* Umami's funnel + attribution reports can correlate marketing-site
|
||||||
|
* traffic with downstream deal outcomes.
|
||||||
|
*
|
||||||
|
* Soft-fail: if Umami is unreachable or misconfigured the call swallows
|
||||||
|
* the error and logs a warning — outcome events shouldn't fail a CRM
|
||||||
|
* mutation.
|
||||||
|
*/
|
||||||
|
export async function trackEvent(
|
||||||
|
portId: string,
|
||||||
|
name: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
url?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tracker = await getTracker(portId);
|
||||||
|
if (!tracker) return;
|
||||||
|
await tracker.track({
|
||||||
|
url: url ?? `/crm/${name}`,
|
||||||
|
name,
|
||||||
|
...(data ? { data } : {}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, name }, 'Umami trackEvent failed (non-blocking)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user