@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 +7 -2
- package/package.json +1 -1
- package/src/analytics/server.test.ts +111 -228
- package/src/analytics/server.ts +58 -42
- package/src/testimonials/embed.test.ts +40 -0
- package/src/testimonials/embed.ts +41 -0
- package/src/testimonials/svelte/TestimonialCarousel.svelte +2 -2
- package/src/testimonials/svelte/TestimonialGrid.svelte +13 -8
- package/src/testimonials/svelte/TestimonialHighlight.svelte +2 -2
- package/src/testimonials/svelte/TestimonialVideoPlayer.svelte +44 -24
- package/src/testimonials/svelte/index.ts +0 -1
- package/src/testimonials/svelte/VideoEmbed.svelte +0 -170
- package/src/testimonials/svelte/pause-icon.svg +0 -1
- package/src/testimonials/svelte/play-icon.svg +0 -1
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({
|
|
30
|
-
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
});
|
|
12
|
+
function answer(body: string, status = 200) {
|
|
13
|
+
fetchMock.mockResolvedValue(new Response(body, { status }));
|
|
14
|
+
}
|
|
34
15
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
fetchMock.mockReset();
|
|
27
|
+
answer('{"cache":"x"}');
|
|
28
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
29
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
30
|
+
});
|
|
42
31
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
);
|
|
47
|
-
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
vi.restoreAllMocks();
|
|
48
35
|
});
|
|
49
36
|
|
|
50
|
-
|
|
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("
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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("
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
71
|
+
id: "cs_123",
|
|
121
72
|
});
|
|
122
73
|
});
|
|
123
74
|
|
|
124
|
-
it("
|
|
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(
|
|
135
|
-
trackServer("risky_event");
|
|
136
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
77
|
+
initServer(config);
|
|
137
78
|
|
|
138
|
-
|
|
139
|
-
});
|
|
79
|
+
await trackServer("page_view", undefined, "/pricing");
|
|
140
80
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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({
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
93
|
+
await trackServer("ua_test");
|
|
167
94
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
183
|
-
|
|
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("
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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("
|
|
208
|
-
const
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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("
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
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(
|
|
277
|
-
|
|
278
|
-
|
|
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
|
});
|
package/src/analytics/server.ts
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-side Umami tracking
|
|
3
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
):
|
|
71
|
-
trackServer(name, { ...data, revenue, currency },
|
|
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
|
|
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
|
-
<
|
|
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" : "
|
|
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.
|
|
163
|
-
|
|
167
|
+
.testimonial-content.can-clamp > p {
|
|
168
|
+
min-height: calc(1.625em * 5);
|
|
164
169
|
}
|
|
165
|
-
.testimonial-content.is-
|
|
166
|
-
|
|
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
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
|
|
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={
|
|
183
|
-
class={`absolute
|
|
184
|
-
aria-label={
|
|
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
|
-
<
|
|
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>
|