@heyramzi/marketing 2.0.1 → 3.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/marketing",
3
- "version": "2.0.1",
3
+ "version": "3.0.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": {
@@ -25,6 +25,7 @@
25
25
  "./analytics/unified": "./src/analytics/unified.ts",
26
26
  "./imagegen": "./src/imagegen/index.ts",
27
27
  "./brand": "./src/brand/index.ts",
28
+ "./brand/deck": "./src/brand/deck.ts",
28
29
  "./testimonials/types": "./src/testimonials/types.ts",
29
30
  "./testimonials/server": "./src/testimonials/server.ts",
30
31
  "./testimonials/render-content": "./src/testimonials/render-content.ts",
@@ -0,0 +1,110 @@
1
+ // The one source of UpSys deck tokens. The app's Svelte layouts and the
2
+ // standalone proposal deck both read these, so the two cannot drift.
3
+
4
+ import { BRAND_HEX } from "#src/brand/index.js";
5
+
6
+ // Flat hex, not oklch: Chromium's print pipeline shifts oklch on the way to PDF.
7
+ // A colour the brand palette already holds comes from BRAND_HEX, never a second literal.
8
+ export const DECK_COLORS = {
9
+ /** Page ground for every slide except the cover and the close. */
10
+ ink: BRAND_HEX.ink,
11
+ /** The cover and the close sit a step lighter so the deck bookends. */
12
+ night: BRAND_HEX.night,
13
+ /** Hairlines and the rules under a section. */
14
+ veil: BRAND_HEX.veil,
15
+ /** Row separators, quieter than veil. */
16
+ dusk: "#0d1a32",
17
+ /** Body text on ink. */
18
+ cream: BRAND_HEX.cream,
19
+ /** Headlines and anything that has to read first. */
20
+ linen: BRAND_HEX.linen,
21
+ indigo: BRAND_HEX.indigo,
22
+ /** Links and the sub-line under a cover title. */
23
+ indigo200: "#b2c2f9",
24
+ /** Lede paragraphs and the say column beside a screenshot. */
25
+ indigo100: "#dbe4ff",
26
+ /** Prices, and the heading of the left column in a two-column slide. */
27
+ sand: BRAND_HEX.sand,
28
+ /** The accent rule. One per slide, never decoration. */
29
+ terra: BRAND_HEX.terra,
30
+ terra200: "#feac9e",
31
+ /** The positive green, from `--success-600`, within 0.3 of terra's contrast on ink. */
32
+ moss: "#2f9f3d",
33
+ /** Captions. */
34
+ dim: "#8fa0c0",
35
+ /** Source lines, the smallest type on any slide. */
36
+ dimmer: "#5d6d8c",
37
+ } as const;
38
+
39
+ /**
40
+ * The type ramp, in design pixels on a 960x540 slide. The standalone deck uses
41
+ * these times 5/3. Keep the ratios; don't invent a size.
42
+ */
43
+ export const DECK_TYPE = {
44
+ coverTitle: { size: 66, weight: 800, tracking: "-0.03em", leading: 1.02 },
45
+ coverSub: { size: 20, weight: 600, tracking: "-0.02em", leading: 1.2 },
46
+ statement: { size: 42, weight: 800, tracking: "-0.03em", leading: 1.05 },
47
+ slideTitle: { size: 32, weight: 800, tracking: "-0.03em", leading: 1.05 },
48
+ sayTitle: { size: 22, weight: 700, tracking: "-0.02em", leading: 1.2 },
49
+ figure: { size: 35, weight: 800, tracking: "-0.04em", leading: 1 },
50
+ price: { size: 40, weight: 800, tracking: "-0.04em", leading: 1 },
51
+ /** One sentence that is the slide. `body` stays at 14 because narrow boxes are measured against it. */
52
+ claim: { size: 18, weight: 400, tracking: "0", leading: 1.45 },
53
+ lede: { size: 17, weight: 400, tracking: "0", leading: 1.5 },
54
+ body: { size: 14, weight: 400, tracking: "0", leading: 1.5 },
55
+ caption: { size: 11, weight: 400, tracking: "0", leading: 1.45 },
56
+ label: { size: 10, weight: 400, tracking: "0.08em", leading: 1.4 },
57
+ } as const;
58
+
59
+ export type DeckTypeToken = keyof typeof DECK_TYPE;
60
+
61
+ /**
62
+ * The cover lockup: the client's mark beside the UpSys logo. Design pixels on a
63
+ * 960x540 slide.
64
+ */
65
+ export const DECK_LOCKUP = {
66
+ /** The tile under the client mark. Client logos ship on white; night eats them. */
67
+ clientTile: 96,
68
+ clientPad: 14,
69
+ clientRadius: 16,
70
+ /** Wordmark height. The mark's own ratio makes the width 4.3x this. */
71
+ agencyLogo: 30,
72
+ /** Between the tile, the rule, and the wordmark. */
73
+ gap: 26,
74
+ /** The hairline that separates the two marks. */
75
+ ruleHeight: 52,
76
+ } as const;
77
+
78
+ /**
79
+ * Slide margins in design pixels on a 960x540 slide. One gutter for every
80
+ * slide, so headlines sit on the same vertical line across the deck.
81
+ */
82
+ export const DECK_SPACE = {
83
+ gutter: 78,
84
+ top: 53,
85
+ bottom: 35,
86
+ /** The accent rule above a slide title. One per slide, never decoration. */
87
+ ruleWidth: 72,
88
+ ruleHeight: 3,
89
+ } as const;
90
+
91
+ /** The UpSys wordmark's ink on the cover ground. The in-app cover and the PDF both draw it. */
92
+ export const DECK_AGENCY_LOGO_FILL = "#F3F3F3";
93
+
94
+ export const DECK_FONTS = {
95
+ heading: "Manrope, system-ui, sans-serif",
96
+ body: "Satoshi, system-ui, sans-serif",
97
+ mono: '"Space Grotesk", ui-monospace, monospace',
98
+ } as const;
99
+
100
+ /** Inline style string for a type token, for `style={...}` on an element. */
101
+ export function deckType(token: DeckTypeToken, family: keyof typeof DECK_FONTS) {
102
+ const t = DECK_TYPE[token];
103
+ return [
104
+ `font-family:${DECK_FONTS[family]}`,
105
+ `font-size:${t.size}px`,
106
+ `font-weight:${t.weight}`,
107
+ `letter-spacing:${t.tracking}`,
108
+ `line-height:${t.leading}`,
109
+ ].join(";");
110
+ }
@@ -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>