@heyramzi/marketing 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,8 +26,13 @@ The analytics module provides wrappers for Umami tracking across different envir
26
26
  ```typescript
27
27
  import { initServer, trackServer } from '@heyramzi/marketing/analytics/server';
28
28
 
29
- initServer({ websiteId: process.env.UMAMI_WEBSITE_ID });
30
- await trackServer('signup', { plan: 'pro' });
29
+ initServer({
30
+ hostUrl: 'https://umami.example.com',
31
+ websiteId: process.env.UMAMI_WEBSITE_ID,
32
+ hostname: 'example.com',
33
+ });
34
+ // Resolves true once Umami recorded it. On a Worker, await it or pass it to waitUntil.
35
+ await trackServer('signup', { plan: 'pro' }, { url: '/signup', id: user.id });
31
36
  ```
32
37
 
33
38
  #### Script tag and browser tracking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/marketing",
3
- "version": "2.1.0",
3
+ "version": "3.1.0",
4
4
  "description": "Umami analytics for client, server, and Svelte, plus a satori-based HTML-to-image pipeline.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,282 +1,165 @@
1
- import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
2
2
 
3
- // The server module holds module-level singletons (client, _config).
4
- // Each test resets them by calling vi.resetModules() + re-importing.
3
+ // The server module holds its config at module level, so each test re-imports it.
5
4
 
6
5
  async function freshImport() {
7
6
  vi.resetModules();
8
7
  return import("#src/analytics/server.js");
9
8
  }
10
9
 
11
- beforeEach(() => {
12
- vi.resetModules();
13
- });
14
-
15
- // ---- initServer ----
16
-
17
- describe("initServer", () => {
18
- it("stores config so that subsequent trackServer calls succeed", async () => {
19
- const mockTrack = vi.fn().mockResolvedValue(undefined);
20
-
21
- vi.doMock("@umami/node", () => ({
22
- Umami: class {
23
- track = mockTrack;
24
- },
25
- }));
26
-
27
- const { initServer, trackServer } = await freshImport();
28
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
29
- trackServer("test_event");
30
- await new Promise((r) => setTimeout(r, 0));
10
+ const fetchMock = vi.fn();
31
11
 
32
- expect(mockTrack).toHaveBeenCalledWith({ name: "test_event" });
33
- });
12
+ function answer(body: string, status = 200) {
13
+ fetchMock.mockResolvedValue(new Response(body, { status }));
14
+ }
34
15
 
35
- it("throws when trackServer is called without prior initServer", async () => {
36
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
37
- vi.doMock("@umami/node", () => ({ Umami: class {} }));
16
+ function sent() {
17
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
18
+ return {
19
+ url,
20
+ headers: init.headers as Record<string, string>,
21
+ body: JSON.parse(init.body as string) as { type: string; payload: Record<string, unknown> },
22
+ };
23
+ }
38
24
 
39
- const { trackServer } = await freshImport();
40
- trackServer("orphan_event");
41
- await new Promise((r) => setTimeout(r, 0));
25
+ beforeEach(() => {
26
+ fetchMock.mockReset();
27
+ answer('{"cache":"x"}');
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ vi.spyOn(console, "error").mockImplementation(() => {});
30
+ });
42
31
 
43
- expect(consoleSpy).toHaveBeenCalledWith(
44
- "[analytics] Server track failed:",
45
- expect.objectContaining({ message: "Call initServer() before tracking events" }),
46
- );
47
- });
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
48
35
  });
49
36
 
50
- // ---- trackServer ----
37
+ const config = {
38
+ hostUrl: "https://umami.example.com",
39
+ websiteId: "abc-123",
40
+ hostname: "example.com",
41
+ };
51
42
 
52
43
  describe("trackServer", () => {
53
- it("builds a payload with only name when no data or url is given", async () => {
54
- const mockTrack = vi.fn().mockResolvedValue(undefined);
55
- vi.doMock("@umami/node", () => ({
56
- Umami: class {
57
- track = mockTrack;
58
- },
59
- }));
60
-
44
+ it("awaits the POST to /api/send and resolves true", async () => {
61
45
  const { initServer, trackServer } = await freshImport();
62
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
63
- trackServer("signup");
64
- await new Promise((r) => setTimeout(r, 0));
65
-
66
- expect(mockTrack).toHaveBeenCalledWith({ name: "signup" });
67
- });
68
-
69
- it("includes data in the payload when provided", async () => {
70
- const mockTrack = vi.fn().mockResolvedValue(undefined);
71
- vi.doMock("@umami/node", () => ({
72
- Umami: class {
73
- track = mockTrack;
74
- },
75
- }));
46
+ initServer(config);
76
47
 
77
- const { initServer, trackServer } = await freshImport();
78
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
79
- trackServer("feature_used", { feature: "export", count: 5 });
80
- await new Promise((r) => setTimeout(r, 0));
48
+ await expect(trackServer("signup")).resolves.toBe(true);
81
49
 
82
- expect(mockTrack).toHaveBeenCalledWith({
83
- name: "feature_used",
84
- data: { feature: "export", count: 5 },
50
+ const { url, headers, body } = sent();
51
+ expect(url).toBe("https://umami.example.com/api/send");
52
+ expect(headers["User-Agent"]).toMatch(/^Mozilla\/5\.0/);
53
+ expect(body).toEqual({
54
+ type: "event",
55
+ payload: { website: "abc-123", hostname: "example.com", url: "/", name: "signup" },
85
56
  });
86
57
  });
87
58
 
88
- it("includes url in the payload when provided", async () => {
89
- const mockTrack = vi.fn().mockResolvedValue(undefined);
90
- vi.doMock("@umami/node", () => ({
91
- Umami: class {
92
- track = mockTrack;
93
- },
94
- }));
95
-
59
+ it("sends data, url and id", async () => {
96
60
  const { initServer, trackServer } = await freshImport();
97
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
98
- trackServer("page_view", undefined, "/pricing");
99
- await new Promise((r) => setTimeout(r, 0));
100
-
101
- expect(mockTrack).toHaveBeenCalledWith({ name: "page_view", url: "/pricing" });
102
- });
61
+ initServer(config);
103
62
 
104
- it("includes both data and url when both are provided", async () => {
105
- const mockTrack = vi.fn().mockResolvedValue(undefined);
106
- vi.doMock("@umami/node", () => ({
107
- Umami: class {
108
- track = mockTrack;
109
- },
110
- }));
63
+ await trackServer("click", { label: "CTA" }, { url: "/home", id: "cs_123" });
111
64
 
112
- const { initServer, trackServer } = await freshImport();
113
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
114
- trackServer("click", { label: "CTA" }, "/home");
115
- await new Promise((r) => setTimeout(r, 0));
116
-
117
- expect(mockTrack).toHaveBeenCalledWith({
65
+ expect(sent().body.payload).toEqual({
66
+ website: "abc-123",
67
+ hostname: "example.com",
68
+ url: "/home",
118
69
  name: "click",
119
70
  data: { label: "CTA" },
120
- url: "/home",
71
+ id: "cs_123",
121
72
  });
122
73
  });
123
74
 
124
- it("logs an error and does not throw when track rejects", async () => {
125
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
126
- const mockTrack = vi.fn().mockRejectedValue(new Error("network failure"));
127
- vi.doMock("@umami/node", () => ({
128
- Umami: class {
129
- track = mockTrack;
130
- },
131
- }));
132
-
75
+ it("still takes the url as a plain string", async () => {
133
76
  const { initServer, trackServer } = await freshImport();
134
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
135
- trackServer("risky_event");
136
- await new Promise((r) => setTimeout(r, 0));
77
+ initServer(config);
137
78
 
138
- expect(consoleSpy).toHaveBeenCalledWith("[analytics] Server track failed:", expect.any(Error));
139
- });
79
+ await trackServer("page_view", undefined, "/pricing");
140
80
 
141
- it("reuses the same Umami instance across multiple calls", async () => {
142
- const mockTrack = vi.fn().mockResolvedValue(undefined);
143
- let constructorCallCount = 0;
144
- vi.doMock("@umami/node", () => ({
145
- Umami: class {
146
- constructor() {
147
- constructorCallCount++;
148
- }
149
- track = mockTrack;
150
- },
151
- }));
81
+ expect(sent().body.payload.url).toBe("/pricing");
82
+ expect(sent().body.payload).not.toHaveProperty("id");
83
+ });
152
84
 
85
+ it("strips a trailing slash from hostUrl and forwards a custom userAgent", async () => {
153
86
  const { initServer, trackServer } = await freshImport();
154
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
155
- trackServer("first");
156
- // Wait for the first getClient() promise to resolve and set the cached client
157
- await new Promise((r) => setTimeout(r, 10));
158
- trackServer("second");
159
- await new Promise((r) => setTimeout(r, 10));
160
-
161
- expect(constructorCallCount).toBe(1);
162
- expect(mockTrack).toHaveBeenCalledTimes(2);
163
- });
164
- });
87
+ initServer({
88
+ ...config,
89
+ hostUrl: "https://umami.example.com/",
90
+ userAgent: "Mozilla/5.0 Seam/1.0",
91
+ });
165
92
 
166
- // ---- trackServerRevenue ----
93
+ await trackServer("ua_test");
167
94
 
168
- describe("trackServerRevenue", () => {
169
- it("tracks a revenue event with revenue and currency merged into data", async () => {
170
- const mockTrack = vi.fn().mockResolvedValue(undefined);
171
- vi.doMock("@umami/node", () => ({
172
- Umami: class {
173
- track = mockTrack;
174
- },
175
- }));
95
+ expect(sent().url).toBe("https://umami.example.com/api/send");
96
+ expect(sent().headers["User-Agent"]).toBe("Mozilla/5.0 Seam/1.0");
97
+ });
176
98
 
177
- const { initServer, trackServerRevenue } = await freshImport();
178
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
179
- trackServerRevenue("purchase", 49.99, "USD");
180
- await new Promise((r) => setTimeout(r, 0));
99
+ // Umami rejects an event with HTTP 200 and the body `beep`.
100
+ it("resolves false when Umami answers beep", async () => {
101
+ answer("beep");
102
+ const { initServer, trackServer } = await freshImport();
103
+ initServer(config);
181
104
 
182
- expect(mockTrack).toHaveBeenCalledWith({
183
- name: "purchase",
184
- data: { revenue: 49.99, currency: "USD" },
185
- });
105
+ await expect(trackServer("dropped")).resolves.toBe(false);
106
+ expect(console.error).toHaveBeenCalled();
186
107
  });
187
108
 
188
- it("merges extra EventData alongside revenue and currency", async () => {
189
- const mockTrack = vi.fn().mockResolvedValue(undefined);
190
- vi.doMock("@umami/node", () => ({
191
- Umami: class {
192
- track = mockTrack;
193
- },
194
- }));
109
+ it("resolves false on an HTTP error", async () => {
110
+ answer("nope", 500);
111
+ const { initServer, trackServer } = await freshImport();
112
+ initServer(config);
195
113
 
196
- const { initServer, trackServerRevenue } = await freshImport();
197
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
198
- trackServerRevenue("subscription", 9.99, "EUR", { plan: "pro" });
199
- await new Promise((r) => setTimeout(r, 0));
114
+ await expect(trackServer("server_error")).resolves.toBe(false);
115
+ });
200
116
 
201
- expect(mockTrack).toHaveBeenCalledWith({
202
- name: "subscription",
203
- data: { plan: "pro", revenue: 9.99, currency: "EUR" },
204
- });
117
+ it("resolves false and never throws when fetch rejects", async () => {
118
+ fetchMock.mockRejectedValue(new Error("network failure"));
119
+ const { initServer, trackServer } = await freshImport();
120
+ initServer(config);
121
+
122
+ await expect(trackServer("risky_event")).resolves.toBe(false);
205
123
  });
206
124
 
207
- it("passes url to the underlying trackServer call", async () => {
208
- const mockTrack = vi.fn().mockResolvedValue(undefined);
209
- vi.doMock("@umami/node", () => ({
210
- Umami: class {
211
- track = mockTrack;
212
- },
213
- }));
125
+ it("resolves false without initServer and sends nothing", async () => {
126
+ const { trackServer } = await freshImport();
214
127
 
215
- const { initServer, trackServerRevenue } = await freshImport();
216
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
217
- trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout");
218
- await new Promise((r) => setTimeout(r, 0));
219
-
220
- expect(mockTrack).toHaveBeenCalledWith({
221
- name: "checkout",
222
- data: { revenue: 19.99, currency: "USD" },
223
- url: "/checkout",
224
- });
128
+ await expect(trackServer("orphan_event")).resolves.toBe(false);
129
+ expect(fetchMock).not.toHaveBeenCalled();
225
130
  });
226
131
  });
227
132
 
228
- // ---- initServer config forwarding ----
229
-
230
- describe("initServer config forwarding", () => {
231
- it("passes hostUrl and websiteId to the Umami constructor", async () => {
232
- let capturedConfig = null;
233
- const mockTrack = vi.fn().mockResolvedValue(undefined);
234
- vi.doMock("@umami/node", () => ({
235
- Umami: class {
236
- constructor(cfg: unknown) {
237
- capturedConfig = cfg;
238
- }
239
- track = mockTrack;
240
- },
241
- }));
242
-
243
- const { initServer, trackServer } = await freshImport();
244
- initServer({ hostUrl: "https://analytics.myapp.io", websiteId: "xyz-789" });
245
- trackServer("init_test");
246
- await new Promise((r) => setTimeout(r, 0));
247
-
248
- expect(capturedConfig).toEqual({
249
- hostUrl: "https://analytics.myapp.io",
250
- websiteId: "xyz-789",
251
- userAgent: "Server",
133
+ describe("trackServerRevenue", () => {
134
+ it("merges revenue and currency into data and forwards url and id", async () => {
135
+ const { initServer, trackServerRevenue } = await freshImport();
136
+ initServer(config);
137
+
138
+ await expect(
139
+ trackServerRevenue(
140
+ "Purchase Complete",
141
+ 9.99,
142
+ "EUR",
143
+ { plan: "pro" },
144
+ { url: "/buy", id: "cs_1" },
145
+ ),
146
+ ).resolves.toBe(true);
147
+
148
+ expect(sent().body.payload).toMatchObject({
149
+ name: "Purchase Complete",
150
+ url: "/buy",
151
+ id: "cs_1",
152
+ data: { plan: "pro", revenue: 9.99, currency: "EUR" },
252
153
  });
253
154
  });
254
155
 
255
- it("forwards a custom userAgent to the Umami constructor", async () => {
256
- let capturedConfig = null;
257
- const mockTrack = vi.fn().mockResolvedValue(undefined);
258
- vi.doMock("@umami/node", () => ({
259
- Umami: class {
260
- constructor(cfg: unknown) {
261
- capturedConfig = cfg;
262
- }
263
- track = mockTrack;
264
- },
265
- }));
266
-
267
- const { initServer, trackServer } = await freshImport();
268
- initServer({
269
- hostUrl: "https://umami.example.com",
270
- websiteId: "abc-123",
271
- userAgent: "MyBot/1.0",
272
- });
273
- trackServer("ua_test");
274
- await new Promise((r) => setTimeout(r, 0));
156
+ it("resolves false when the send fails", async () => {
157
+ answer("beep");
158
+ const { initServer, trackServerRevenue } = await freshImport();
159
+ initServer(config);
275
160
 
276
- expect(capturedConfig).toEqual({
277
- hostUrl: "https://umami.example.com",
278
- websiteId: "abc-123",
279
- userAgent: "MyBot/1.0",
280
- });
161
+ await expect(
162
+ trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout"),
163
+ ).resolves.toBe(false);
281
164
  });
282
165
  });
@@ -1,32 +1,31 @@
1
1
  /**
2
- * Server-side Umami tracking via @umami/node.
3
- * Reliable for revenue and critical events — bypasses ad blockers.
4
- *
5
- * Requires `@umami/node` peer dependency.
2
+ * Server-side Umami tracking. A Worker cancels a fetch still in flight once the response
3
+ * returns, so await each tracker or hand it to `waitUntil`.
6
4
  */
7
5
 
8
6
  import type { EventData } from "#src/analytics/types.js";
9
-
10
- type UmamiNodeClient = {
11
- track: (payload: Record<string, unknown>) => Promise<unknown>;
12
- };
13
-
14
- type UmamiNodeModule = {
15
- Umami: new (config: {
16
- hostUrl: string;
17
- websiteId: string;
18
- userAgent?: string;
19
- }) => UmamiNodeClient;
20
- };
21
-
22
- let client: UmamiNodeClient | null = null;
7
+ import { buildSendBody, UMAMI_ENDPOINTS } from "#src/analytics/umami/protocol.js";
23
8
 
24
9
  type ServerConfig = {
25
10
  hostUrl: string;
26
11
  websiteId: string;
12
+ /** Site hostname, such as `getseam.app`. Without it Umami logs the event under `localhost`. */
13
+ hostname?: string;
14
+ /** Umami answers a non-browser user agent with `beep` and drops the event. */
27
15
  userAgent?: string;
28
16
  };
29
17
 
18
+ /** Per-event options. A string is the url, as in 3.0. */
19
+ export type ServerEventOptions = {
20
+ url?: string;
21
+ /** Umami keys the session on it. Without one, every event shares the Worker's session. */
22
+ id?: string;
23
+ };
24
+
25
+ // Umami's bot filter answers `beep` to any agent containing "Server" (tested 19 Oct 2026).
26
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 Marketing/1.0";
27
+ const TIMEOUT_MS = 3000;
28
+
30
29
  let _config: ServerConfig | null = null;
31
30
 
32
31
  /** Initialize the server-side Umami client. Call once at startup. */
@@ -34,30 +33,47 @@ export function initServer(config: ServerConfig): void {
34
33
  _config = config;
35
34
  }
36
35
 
37
- async function getClient(): Promise<UmamiNodeClient> {
38
- if (client) return client;
39
- if (!_config) throw new Error("Call initServer() before tracking events");
40
-
41
- const { Umami } = (await import("@umami/node")) as UmamiNodeModule;
42
- client = new Umami({
43
- hostUrl: _config.hostUrl,
44
- websiteId: _config.websiteId,
45
- userAgent: _config.userAgent ?? "Server",
46
- });
47
- return client;
48
- }
36
+ /**
37
+ * Sends one event and checks Umami recorded it. Never throws.
38
+ * Resolves false on a timeout, an HTTP error or a `beep` rejection.
39
+ */
40
+ export async function trackServer(
41
+ name: string,
42
+ data?: EventData,
43
+ options?: string | ServerEventOptions,
44
+ ): Promise<boolean> {
45
+ const { url, id } =
46
+ typeof options === "string" ? { url: options, id: undefined } : (options ?? {});
47
+ try {
48
+ if (!_config) throw new Error("Call initServer() before tracking events");
49
49
 
50
- /** Fire-and-forget server-side event tracking. Never throws. */
51
- export function trackServer(name: string, data?: EventData, url?: string): void {
52
- const payload: { name: string; data?: EventData; url?: string } = { name };
53
- if (data) payload.data = data;
54
- if (url) payload.url = url;
50
+ const body = buildSendBody({
51
+ websiteId: _config.websiteId,
52
+ hostname: _config.hostname,
53
+ name,
54
+ url,
55
+ data,
56
+ });
57
+ const payload = id ? { ...body.payload, id } : body.payload;
55
58
 
56
- getClient()
57
- .then((c) => c.track(payload))
58
- .catch((err: unknown) => {
59
- console.error("[analytics] Server track failed:", err);
59
+ const response = await fetch(`${_config.hostUrl.replace(/\/$/, "")}${UMAMI_ENDPOINTS.send}`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "User-Agent": _config.userAgent ?? DEFAULT_USER_AGENT,
64
+ },
65
+ body: JSON.stringify({ ...body, payload }),
66
+ signal: AbortSignal.timeout(TIMEOUT_MS),
60
67
  });
68
+ const text = await response.text();
69
+ if (!response.ok || text.includes("beep")) {
70
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 120)}`);
71
+ }
72
+ return true;
73
+ } catch (err: unknown) {
74
+ console.error(`[analytics] Server track failed for ${name}:`, err);
75
+ return false;
76
+ }
61
77
  }
62
78
 
63
79
  /** Track a server-side revenue event. Revenue must be in dollars (not cents). */
@@ -66,7 +82,7 @@ export function trackServerRevenue(
66
82
  revenue: number,
67
83
  currency: string,
68
84
  data?: EventData,
69
- url?: string,
70
- ): void {
71
- trackServer(name, { ...data, revenue, currency }, url);
85
+ options?: string | ServerEventOptions,
86
+ ): Promise<boolean> {
87
+ return trackServer(name, { ...data, revenue, currency }, options);
72
88
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getEmbedUrl } from "#src/testimonials/embed.js";
3
+
4
+ describe("getEmbedUrl", () => {
5
+ it("turns a youtube watch link into its embed url", () => {
6
+ expect(getEmbedUrl("https://www.youtube.com/watch?v=abc123")).toBe(
7
+ "https://www.youtube.com/embed/abc123",
8
+ );
9
+ });
10
+
11
+ it("keeps a youtube embed link as it is", () => {
12
+ expect(getEmbedUrl("https://youtube.com/embed/abc123")).toBe("https://youtube.com/embed/abc123");
13
+ });
14
+
15
+ it("turns a youtu.be short link into its embed url", () => {
16
+ expect(getEmbedUrl("https://youtu.be/abc123")).toBe("https://www.youtube.com/embed/abc123");
17
+ });
18
+
19
+ it("turns a vimeo link into the player url", () => {
20
+ expect(getEmbedUrl("https://vimeo.com/76979871")).toBe("https://player.vimeo.com/video/76979871");
21
+ });
22
+
23
+ it("turns a loom share link into its embed url", () => {
24
+ expect(getEmbedUrl("https://www.loom.com/share/deadbeef?sid=1")).toBe(
25
+ "https://www.loom.com/embed/deadbeef",
26
+ );
27
+ });
28
+
29
+ it("returns null for a file url so the <video> path runs", () => {
30
+ expect(getEmbedUrl("https://example.com/clip.mp4")).toBeNull();
31
+ });
32
+
33
+ it("returns null for a youtube link with no video id", () => {
34
+ expect(getEmbedUrl("https://www.youtube.com/")).toBeNull();
35
+ });
36
+
37
+ it("returns null for text that is not a url", () => {
38
+ expect(getEmbedUrl("not a url")).toBeNull();
39
+ });
40
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * WHY: a testimonial's `videoUrl` is a pasted link, so YouTube, Vimeo and Loom arrive as watch or share pages that refuse to load inside an iframe.
3
+ */
4
+
5
+ /*
6
+ * Returns the iframe-safe url for a known host, and null for anything else, so the caller falls back to a `<video>`.
7
+ */
8
+ export function getEmbedUrl(videoUrl: string): string | null {
9
+ let parsed: URL;
10
+ try {
11
+ parsed = new URL(videoUrl);
12
+ } catch {
13
+ return null;
14
+ }
15
+
16
+ const { hostname, pathname } = parsed;
17
+
18
+ if (hostname === "www.youtube.com" || hostname === "youtube.com") {
19
+ if (pathname.startsWith("/embed/")) return videoUrl;
20
+ const videoId = parsed.searchParams.get("v");
21
+ return videoId ? `https://www.youtube.com/embed/${videoId}` : null;
22
+ }
23
+
24
+ if (hostname === "youtu.be") {
25
+ const videoId = pathname.slice(1);
26
+ return videoId ? `https://www.youtube.com/embed/${videoId}` : null;
27
+ }
28
+
29
+ if (hostname === "vimeo.com" || hostname === "www.vimeo.com") {
30
+ const videoId = pathname.slice(1);
31
+ return videoId ? `https://player.vimeo.com/video/${videoId}` : null;
32
+ }
33
+
34
+ if (hostname === "www.loom.com" || hostname === "loom.com") {
35
+ if (pathname.startsWith("/embed/")) return videoUrl;
36
+ const shareId = pathname.match(/\/share\/([^?]+)/)?.[1];
37
+ return shareId ? `https://www.loom.com/embed/${shareId}` : null;
38
+ }
39
+
40
+ return null;
41
+ }
@@ -5,7 +5,7 @@
5
5
  import type { PublicTestimonial } from "#src/testimonials/types.js";
6
6
  import { renderTestimonialContent } from "#src/testimonials/render-content.js";
7
7
  import StarRating from "#src/testimonials/svelte/StarRating.svelte";
8
- import VideoEmbed from "#src/testimonials/svelte/VideoEmbed.svelte";
8
+ import TestimonialVideoPlayer from "#src/testimonials/svelte/TestimonialVideoPlayer.svelte";
9
9
 
10
10
  // =========================
11
11
  // PROPS
@@ -68,7 +68,7 @@
68
68
  >
69
69
  {#if testimonial.videoUrl}
70
70
  <div class="mb-4">
71
- <VideoEmbed url={testimonial.videoUrl} />
71
+ <TestimonialVideoPlayer src={testimonial.videoUrl} nativeControls />
72
72
  </div>
73
73
  {/if}
74
74
 
@@ -75,7 +75,7 @@
75
75
  >
76
76
  {#if testimonial.videoUrl}
77
77
  {@const cap = getCaption ? getCaption(testimonial.videoUrl) : null}
78
- <TestimonialVideoPlayer src={testimonial.videoUrl} caption={cap} />
78
+ <TestimonialVideoPlayer src={testimonial.videoUrl} caption={cap} rounded={false} />
79
79
  {/if}
80
80
 
81
81
  <div class="p-5">
@@ -87,7 +87,7 @@
87
87
 
88
88
  <blockquote class="mb-3 text-sm leading-relaxed text-foreground">
89
89
  <div
90
- class={`testimonial-content ${needsClamp && !isExpanded ? "is-clamped" : "is-expanded"}`}
90
+ class={`testimonial-content ${needsClamp ? "can-clamp" : ""} ${needsClamp && !isExpanded ? "is-clamped" : ""}`}
91
91
  >
92
92
  <p>"<!-- eslint-disable-next-line svelte/no-at-html-tags -->{@html rendered}"</p>
93
93
  </div>
@@ -153,17 +153,22 @@
153
153
  <!-- STYLE -->
154
154
  <!-- =========================== -->
155
155
  <style>
156
+ /* WHY grid rows: 0fr to 1fr animates to the text's real height with no
157
+ max-height guess, and the row never goes below the five-line floor. */
156
158
  .testimonial-content {
157
159
  position: relative;
160
+ display: grid;
161
+ grid-template-rows: 1fr;
162
+ transition: grid-template-rows 360ms cubic-bezier(0.4, 0, 0.2, 1);
163
+ }
164
+ .testimonial-content > p {
158
165
  overflow: hidden;
159
- transition: max-height 360ms cubic-bezier(0.4, 0, 0.2, 1);
160
- will-change: max-height;
161
166
  }
162
- .testimonial-content.is-clamped {
163
- max-height: calc(1.625em * 5);
167
+ .testimonial-content.can-clamp > p {
168
+ min-height: calc(1.625em * 5);
164
169
  }
165
- .testimonial-content.is-expanded {
166
- max-height: 200vh;
170
+ .testimonial-content.is-clamped {
171
+ grid-template-rows: 0fr;
167
172
  }
168
173
  .testimonial-content.is-clamped::after {
169
174
  content: "";
@@ -5,7 +5,7 @@
5
5
  import type { PublicTestimonial } from "#src/testimonials/types.js";
6
6
  import { renderTestimonialContent } from "#src/testimonials/render-content.js";
7
7
  import StarRating from "#src/testimonials/svelte/StarRating.svelte";
8
- import VideoEmbed from "#src/testimonials/svelte/VideoEmbed.svelte";
8
+ import TestimonialVideoPlayer from "#src/testimonials/svelte/TestimonialVideoPlayer.svelte";
9
9
  import quoteIcon from "#src/testimonials/svelte/quote-icon.svg?raw";
10
10
 
11
11
  // =========================
@@ -45,7 +45,7 @@
45
45
 
46
46
  {#if featured.videoUrl}
47
47
  <div class="mb-6">
48
- <VideoEmbed url={featured.videoUrl} />
48
+ <TestimonialVideoPlayer src={featured.videoUrl} nativeControls />
49
49
  </div>
50
50
  {/if}
51
51
 
@@ -5,6 +5,7 @@
5
5
  import Play from "phosphor-svelte/lib/Play";
6
6
  import Pause from "phosphor-svelte/lib/Pause";
7
7
  import ClosedCaptioning from "phosphor-svelte/lib/ClosedCaptioning";
8
+ import { getEmbedUrl } from "#src/testimonials/embed.js";
8
9
  import { getMuxPlaybackId, getMuxPosterUrl } from "#src/testimonials/mux.js";
9
10
  import type { TestimonialCaption } from "#src/testimonials/svelte/types.js";
10
11
 
@@ -15,13 +16,26 @@
15
16
  src: string;
16
17
  caption?: TestimonialCaption | null;
17
18
  fit?: "cover" | "contain";
19
+ rounded?: boolean;
20
+ /** Browser controls on a file video, instead of the centred play button and caption toggle. */
21
+ nativeControls?: boolean;
22
+ /** Accessible name of the YouTube, Vimeo or Loom frame. */
23
+ title?: string;
18
24
  class?: string;
19
25
  };
20
26
 
21
27
  // =========================
22
28
  // PROPS
23
29
  // =========================
24
- let { src, caption = null, fit = "cover", class: extraClass = "" }: Props = $props();
30
+ let {
31
+ src,
32
+ caption = null,
33
+ fit = "cover",
34
+ rounded = true,
35
+ nativeControls = false,
36
+ title = "Video testimonial",
37
+ class: extraClass = "",
38
+ }: Props = $props();
25
39
 
26
40
  // =========================
27
41
  // STATE
@@ -37,6 +51,7 @@
37
51
  // DERIVED
38
52
  // =========================
39
53
  const muxPlaybackId = $derived(getMuxPlaybackId(src));
54
+ const embedUrl = $derived(muxPlaybackId ? null : getEmbedUrl(src));
40
55
 
41
56
  // =========================
42
57
  // FUNCTIONS
@@ -123,7 +138,7 @@
123
138
  <!-- MARKUP -->
124
139
  <!-- =========================== -->
125
140
  <div
126
- class={`group relative w-full overflow-hidden bg-black ${extraClass}`}
141
+ class={`group relative w-full overflow-hidden bg-black ${rounded ? "rounded-lg" : ""} ${extraClass}`}
127
142
  style={aspectRatio ? `aspect-ratio: ${aspectRatio};` : "aspect-ratio: 16 / 9;"}
128
143
  onmousemove={showControls}
129
144
  onmouseleave={() => playing && (controlsVisible = false)}
@@ -137,10 +152,13 @@
137
152
  stream-type="on-demand"
138
153
  class="testimonial-mux-player size-full"
139
154
  ></mux-player>
155
+ {:else if embedUrl}
156
+ <iframe src={embedUrl} class="absolute inset-0 size-full" allowfullscreen {title}></iframe>
140
157
  {:else}
141
158
  <video
142
159
  bind:this={videoEl}
143
160
  {src}
161
+ controls={nativeControls}
144
162
  class={`size-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
145
163
  preload="metadata"
146
164
  playsinline
@@ -162,31 +180,33 @@
162
180
  <track kind="captions" src="" srclang="en" label="Captions" default />
163
181
  {/if}
164
182
  </video>
165
- <button
166
- onclick={togglePlay}
167
- class={`absolute inset-0 flex cursor-pointer items-center justify-center border-none bg-transparent text-white transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0"}`}
168
- aria-label={playing ? "Pause" : "Play"}
169
- >
170
- <span
171
- class="flex size-16 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm transition-colors group-hover:bg-black/75"
172
- >
173
- {#if playing}
174
- <Pause size={28} weight="fill" />
175
- {:else}
176
- <Play size={28} weight="fill" />
177
- {/if}
178
- </span>
179
- </button>
180
- {#if caption}
183
+ {#if !nativeControls}
181
184
  <button
182
- onclick={toggleCaptions}
183
- class={`absolute bottom-2.5 right-2.5 flex size-7 cursor-pointer items-center justify-center rounded text-white backdrop-blur-sm transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "pointer-events-none opacity-0"} ${captionsOn ? "border border-white/70 bg-white/20" : "border border-white/20 bg-black/55 hover:bg-black/75"}`}
184
- aria-label={captionsOn ? "Hide captions" : "Show captions"}
185
- aria-pressed={captionsOn}
186
- title={captionsOn ? "Hide captions" : "Show captions"}
185
+ onclick={togglePlay}
186
+ class={`absolute inset-0 flex cursor-pointer items-center justify-center border-none bg-transparent text-white transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0"}`}
187
+ aria-label={playing ? "Pause" : "Play"}
187
188
  >
188
- <ClosedCaptioning size={14} weight={captionsOn ? "fill" : "regular"} />
189
+ <span
190
+ class="flex size-16 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm transition-colors group-hover:bg-black/75"
191
+ >
192
+ {#if playing}
193
+ <Pause size={28} weight="fill" />
194
+ {:else}
195
+ <Play size={28} weight="fill" />
196
+ {/if}
197
+ </span>
189
198
  </button>
199
+ {#if caption}
200
+ <button
201
+ onclick={toggleCaptions}
202
+ class={`absolute bottom-2.5 right-2.5 flex size-7 cursor-pointer items-center justify-center rounded text-white backdrop-blur-sm transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "pointer-events-none opacity-0"} ${captionsOn ? "border border-white/70 bg-white/20" : "border border-white/20 bg-black/55 hover:bg-black/75"}`}
203
+ aria-label={captionsOn ? "Hide captions" : "Show captions"}
204
+ aria-pressed={captionsOn}
205
+ title={captionsOn ? "Hide captions" : "Show captions"}
206
+ >
207
+ <ClosedCaptioning size={14} weight={captionsOn ? "fill" : "regular"} />
208
+ </button>
209
+ {/if}
190
210
  {/if}
191
211
  {/if}
192
212
  </div>
@@ -5,7 +5,6 @@ export { default as TestimonialCompact } from "#src/testimonials/svelte/Testimon
5
5
  export { default as TestimonialHighlight } from "#src/testimonials/svelte/TestimonialHighlight.svelte";
6
6
  export { default as TestimonialVideoPlayer } from "#src/testimonials/svelte/TestimonialVideoPlayer.svelte";
7
7
  export { default as StarRating } from "#src/testimonials/svelte/StarRating.svelte";
8
- export { default as VideoEmbed } from "#src/testimonials/svelte/VideoEmbed.svelte";
9
8
 
10
9
  export { renderTestimonialContent } from "#src/testimonials/render-content.js";
11
10
  export { getMuxPlaybackId, getMuxPosterUrl } from "#src/testimonials/mux.js";
@@ -1,170 +0,0 @@
1
- <script lang="ts">
2
- // =========================
3
- // IMPORTS
4
- // =========================
5
- import pauseIcon from "#src/testimonials/svelte/pause-icon.svg?raw";
6
- import playIcon from "#src/testimonials/svelte/play-icon.svg?raw";
7
-
8
- // =========================
9
- // PROPS
10
- // =========================
11
- let {
12
- url,
13
- rounded = true,
14
- playOnly = false,
15
- }: { url: string; rounded?: boolean; playOnly?: boolean } = $props();
16
-
17
- // =========================
18
- // STATE
19
- // =========================
20
- let videoEl = $state<HTMLVideoElement | null>(null);
21
- let playing = $state(false);
22
-
23
- // =========================
24
- // DERIVED
25
- // =========================
26
- const VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov"];
27
-
28
- const isDirectVideo = $derived.by(() => {
29
- try {
30
- const { pathname } = new URL(url);
31
- return VIDEO_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
32
- } catch {
33
- return false;
34
- }
35
- });
36
-
37
- const embedUrl = $derived.by(() => {
38
- if (isDirectVideo) return null;
39
- try {
40
- const parsed = new URL(url);
41
-
42
- if (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") {
43
- if (parsed.pathname.startsWith("/embed/")) return url;
44
- const videoId = parsed.searchParams.get("v");
45
- if (videoId) return `https://www.youtube.com/embed/${videoId}`;
46
- }
47
-
48
- if (parsed.hostname === "youtu.be") {
49
- const videoId = parsed.pathname.slice(1);
50
- if (videoId) return `https://www.youtube.com/embed/${videoId}`;
51
- }
52
-
53
- if (parsed.hostname === "vimeo.com" || parsed.hostname === "www.vimeo.com") {
54
- const videoId = parsed.pathname.slice(1);
55
- if (videoId) return `https://player.vimeo.com/video/${videoId}`;
56
- }
57
-
58
- if (parsed.hostname === "www.loom.com" || parsed.hostname === "loom.com") {
59
- if (parsed.pathname.startsWith("/embed/")) return url;
60
- const match = parsed.pathname.match(/\/share\/([^?]+)/);
61
- if (match?.[1]) return `https://www.loom.com/embed/${match[1]}`;
62
- }
63
-
64
- return null;
65
- } catch {
66
- return null;
67
- }
68
- });
69
-
70
- const roundingClass = $derived(rounded ? "rounded-lg" : "");
71
-
72
- // =========================
73
- // FUNCTIONS
74
- // =========================
75
- function togglePlay() {
76
- if (!videoEl) return;
77
- if (videoEl.paused) {
78
- videoEl.play();
79
- playing = true;
80
- } else {
81
- videoEl.pause();
82
- playing = false;
83
- }
84
- }
85
- </script>
86
-
87
- <!-- =========================== -->
88
- <!-- MARKUP -->
89
- <!-- =========================== -->
90
- {#if isDirectVideo}
91
- {#if playOnly}
92
- <div class="relative aspect-video w-full overflow-hidden {roundingClass}">
93
- <video
94
- bind:this={videoEl}
95
- src={url}
96
- class="size-full object-cover"
97
- preload="metadata"
98
- playsinline
99
- onended={() => (playing = false)}
100
- ></video>
101
- <button onclick={togglePlay} class="play-btn" aria-label={playing ? "Pause" : "Play"}>
102
- {#if playing}
103
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
104
- {@html pauseIcon}
105
- {:else}
106
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
107
- {@html playIcon}
108
- {/if}
109
- </button>
110
- </div>
111
- {:else}
112
- <video
113
- src={url}
114
- class="aspect-video w-full object-cover {roundingClass}"
115
- controls
116
- preload="metadata"
117
- playsinline
118
- ></video>
119
- {/if}
120
- {:else if embedUrl}
121
- <div class="relative aspect-video w-full overflow-hidden {roundingClass}">
122
- <iframe
123
- src={embedUrl}
124
- class="absolute inset-0 size-full"
125
- allowfullscreen
126
- title="Video testimonial"
127
- ></iframe>
128
- </div>
129
- {/if}
130
-
131
- <!-- =========================== -->
132
- <!-- STYLE -->
133
- <!-- =========================== -->
134
- <style>
135
- .play-btn {
136
- position: absolute;
137
- inset: 0;
138
- display: flex;
139
- align-items: center;
140
- justify-content: center;
141
- background: transparent;
142
- border: none;
143
- cursor: pointer;
144
- color: white;
145
- }
146
-
147
- .play-btn::before {
148
- content: "";
149
- position: absolute;
150
- width: 64px;
151
- height: 64px;
152
- border-radius: 50%;
153
- background: rgba(0, 0, 0, 0.55);
154
- backdrop-filter: blur(4px);
155
- transition: background 0.15s ease;
156
- }
157
-
158
- .play-btn:hover::before {
159
- background: rgba(0, 0, 0, 0.75);
160
- }
161
-
162
- .play-btn :global(svg) {
163
- position: relative;
164
- z-index: 1;
165
- }
166
-
167
- .play-btn:active {
168
- transform: scale(0.97);
169
- }
170
- </style>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" /></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true"><path d="M8 5.14v14l11-7-11-7z" /></svg>