@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 +2 -1
- package/src/brand/deck.ts +110 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/marketing",
|
|
3
|
-
"version": "
|
|
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
|
|
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>
|