Skip to content

Commit d34502d

Browse files
committed
Add AI Workshop Features and Localization Enhancements
- Introduced the AI Workshop landing page with bilingual support, including localized content for English and Indonesian. - Implemented new components for the AI Workshop, such as the teaser and sticky call-to-action, enhancing user engagement. - Added functions to generate structured data (JSON-LD) for SEO optimization and improved metadata handling. - Created comprehensive test cases for new functionalities, ensuring robust validation of the AI Workshop features. - Updated localization files to include new strings and assets related to the AI Workshop, ensuring accurate representation across languages. These changes aim to enhance the visibility and accessibility of the AI Workshop, providing users with clear and engaging information in their preferred language.
1 parent e271a7d commit d34502d

23 files changed

Lines changed: 2340 additions & 0 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
AI_WORKSHOP_CONTACT_EMAIL,
5+
buildAiWorkshopMailto
6+
} from "./build-ai-workshop-mailto";
7+
8+
describe("buildAiWorkshopMailto", () => {
9+
it("builds a mailto link with default recipient and encoded query params", () => {
10+
// Setup
11+
const subject = "AI workshop inquiry";
12+
const body = "Company:\nName:\n";
13+
14+
// Act
15+
const href = buildAiWorkshopMailto({ subject, body });
16+
17+
// Assert
18+
expect(href).toBe(
19+
`mailto:${AI_WORKSHOP_CONTACT_EMAIL}?subject=${encodeURIComponent(
20+
subject
21+
)}&body=${encodeURIComponent(body)}`
22+
);
23+
});
24+
25+
it("uses a custom recipient when provided", () => {
26+
// Act
27+
const href = buildAiWorkshopMailto({
28+
subject: "Test",
29+
body: "Body",
30+
recipient: "custom@example.com"
31+
});
32+
33+
// Assert
34+
expect(href.startsWith("mailto:custom@example.com?")).toBe(true);
35+
});
36+
37+
it("encodes special characters in subject and body", () => {
38+
// Setup
39+
const subject = "Minat workshop AI: 8–15 orang";
40+
const body = "Halo & selamat?";
41+
42+
// Act
43+
const href = buildAiWorkshopMailto({ subject, body });
44+
45+
// Assert
46+
expect(href).toContain(encodeURIComponent(subject));
47+
expect(href).toContain(encodeURIComponent(body));
48+
});
49+
});
50+
51+
describe("AI_WORKSHOP_CONTACT_EMAIL", () => {
52+
it("points to the Hyperjump solutions inbox", () => {
53+
// Act
54+
const email = AI_WORKSHOP_CONTACT_EMAIL;
55+
56+
// Assert
57+
expect(email).toBe("solution@hyperjump.tech");
58+
});
59+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const AI_WORKSHOP_CONTACT_EMAIL = "solution@hyperjump.tech";
2+
3+
export type BuildAiWorkshopMailtoOptions = {
4+
/** Email subject line */
5+
subject: string;
6+
/** Prefilled message body */
7+
body: string;
8+
/** Recipient address; defaults to solution@hyperjump.tech */
9+
recipient?: string;
10+
};
11+
12+
/**
13+
* Builds a mailto URL for AI workshop inquiries with encoded subject and body.
14+
*
15+
* @param options - Subject, body, and optional recipient override
16+
* @returns Encoded mailto href ready for anchor elements
17+
*/
18+
export function buildAiWorkshopMailto({
19+
subject,
20+
body,
21+
recipient = AI_WORKSHOP_CONTACT_EMAIL
22+
}: BuildAiWorkshopMailtoOptions): string {
23+
return `mailto:${recipient}?subject=${encodeURIComponent(
24+
subject
25+
)}&body=${encodeURIComponent(body)}`;
26+
}
27+
28+
export { AI_WORKSHOP_CONTACT_EMAIL };
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
import {
6+
AI_WORKSHOP_FACILITATOR_IMAGE_PATH,
7+
AI_WORKSHOP_OG_IMAGE_HEIGHT,
8+
AI_WORKSHOP_OG_IMAGE_PATH,
9+
AI_WORKSHOP_OG_IMAGE_PATHS,
10+
AI_WORKSHOP_OG_IMAGE_WIDTH,
11+
AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH,
12+
AI_WORKSHOP_ROOM_IMAGE_PATH,
13+
getAiWorkshopFacilitatorPhoto,
14+
getAiWorkshopOgImage,
15+
getAiWorkshopOgImagePath,
16+
getAiWorkshopParticipantsPhoto,
17+
getAiWorkshopRoomPhoto,
18+
getAiWorkshopStructuredImages
19+
} from "./get-ai-workshop-assets";
20+
21+
/**
22+
* Reads width and height from a PNG file's IHDR chunk.
23+
*
24+
* @param buffer - PNG file bytes
25+
* @returns Pixel dimensions
26+
*/
27+
function getPngSize(buffer: Buffer) {
28+
return {
29+
width: buffer.readUInt32BE(16),
30+
height: buffer.readUInt32BE(20)
31+
};
32+
}
33+
34+
describe("getAiWorkshopFacilitatorPhoto", () => {
35+
it("returns the facilitator JPEG path and native dimensions", () => {
36+
// Act
37+
const photo = getAiWorkshopFacilitatorPhoto();
38+
39+
// Assert
40+
expect(photo).toEqual({
41+
src: AI_WORKSHOP_FACILITATOR_IMAGE_PATH,
42+
width: 1024,
43+
height: 768
44+
});
45+
});
46+
});
47+
48+
describe("getAiWorkshopParticipantsPhoto", () => {
49+
it("returns the participants JPEG path and native dimensions", () => {
50+
// Act
51+
const photo = getAiWorkshopParticipantsPhoto();
52+
53+
// Assert
54+
expect(photo).toEqual({
55+
src: AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH,
56+
width: 1024,
57+
height: 769
58+
});
59+
});
60+
});
61+
62+
describe("getAiWorkshopRoomPhoto", () => {
63+
it("returns the room JPEG path and native dimensions", () => {
64+
// Act
65+
const photo = getAiWorkshopRoomPhoto();
66+
67+
// Assert
68+
expect(photo).toEqual({
69+
src: AI_WORKSHOP_ROOM_IMAGE_PATH,
70+
width: 1024,
71+
height: 576
72+
});
73+
});
74+
});
75+
76+
describe("AI_WORKSHOP_OG_IMAGE_PATH", () => {
77+
it("points to a 1200x630 PNG in public/", () => {
78+
// Setup
79+
expect(AI_WORKSHOP_OG_IMAGE_PATH).toBe("/images/ai-workshop/og.png");
80+
const ogPng = readFileSync(
81+
join(
82+
process.cwd(),
83+
"public",
84+
AI_WORKSHOP_OG_IMAGE_PATH.replace(/^\//, "")
85+
)
86+
);
87+
88+
// Act
89+
const size = getPngSize(ogPng);
90+
91+
// Assert
92+
expect(size).toEqual({
93+
width: AI_WORKSHOP_OG_IMAGE_WIDTH,
94+
height: AI_WORKSHOP_OG_IMAGE_HEIGHT
95+
});
96+
});
97+
98+
it("ships locale-specific 1200x630 Open Graph cards", () => {
99+
// Act / Assert
100+
for (const path of Object.values(AI_WORKSHOP_OG_IMAGE_PATHS)) {
101+
const png = readFileSync(
102+
join(process.cwd(), "public", path.replace(/^\//, ""))
103+
);
104+
expect(getPngSize(png)).toEqual({ width: 1200, height: 630 });
105+
}
106+
});
107+
108+
it("reads IHDR dimensions from a PNG buffer", () => {
109+
// Setup
110+
const png = Buffer.alloc(24);
111+
png.writeUInt32BE(1600, 16);
112+
png.writeUInt32BE(900, 20);
113+
114+
// Act
115+
const size = getPngSize(png);
116+
117+
// Assert
118+
expect(size).toEqual({ width: 1600, height: 900 });
119+
});
120+
});
121+
122+
describe("getAiWorkshopOgImagePath", () => {
123+
it("returns the English card for en and the Indonesian card for id", () => {
124+
// Act / Assert
125+
expect(getAiWorkshopOgImagePath("en")).toBe(
126+
"/images/ai-workshop/og-en.png"
127+
);
128+
expect(getAiWorkshopOgImagePath("id")).toBe(
129+
"/images/ai-workshop/og-id.png"
130+
);
131+
});
132+
});
133+
134+
describe("getAiWorkshopOgImage", () => {
135+
it("returns an absolute 1200x630 image for the active locale", () => {
136+
// Act
137+
const image = getAiWorkshopOgImage({
138+
lang: "id",
139+
siteUrl: "https://hyperjump.tech",
140+
alt: "Workshop AI"
141+
});
142+
143+
// Assert
144+
expect(image).toEqual({
145+
url: "https://hyperjump.tech/images/ai-workshop/og-id.png",
146+
width: 1200,
147+
height: 630,
148+
alt: "Workshop AI"
149+
});
150+
});
151+
});
152+
153+
describe("getAiWorkshopStructuredImages", () => {
154+
it("lists the OG card first, then the three workshop photos", () => {
155+
// Act
156+
const images = getAiWorkshopStructuredImages({
157+
lang: "en",
158+
siteUrl: "https://hyperjump.tech"
159+
});
160+
161+
// Assert
162+
expect(images[0]).toBe(
163+
"https://hyperjump.tech/images/ai-workshop/og-en.png"
164+
);
165+
expect(images).toHaveLength(4);
166+
expect(images[1]).toContain(AI_WORKSHOP_FACILITATOR_IMAGE_PATH);
167+
expect(images[2]).toContain(AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH);
168+
expect(images[3]).toContain(AI_WORKSHOP_ROOM_IMAGE_PATH);
169+
});
170+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { SupportedLanguage } from "@/locales/.generated/types";
2+
3+
/** Fallback Open Graph path (English card). */
4+
export const AI_WORKSHOP_OG_IMAGE_PATH = "/images/ai-workshop/og.png";
5+
6+
/** Locale-specific 1200×630 Open Graph cards. */
7+
export const AI_WORKSHOP_OG_IMAGE_PATHS = {
8+
en: "/images/ai-workshop/og-en.png",
9+
id: "/images/ai-workshop/og-id.png"
10+
} as const;
11+
12+
/** Public path to the facilitator photo used in the hero. */
13+
export const AI_WORKSHOP_FACILITATOR_IMAGE_PATH =
14+
"/images/ai-workshop/facilitator.jpg";
15+
16+
/** Public path to the table-of-participants photo. */
17+
export const AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH =
18+
"/images/ai-workshop/participants.jpg";
19+
20+
/** Public path to the wide room photo. */
21+
export const AI_WORKSHOP_ROOM_IMAGE_PATH = "/images/ai-workshop/room.jpg";
22+
23+
export const AI_WORKSHOP_OG_IMAGE_WIDTH = 1200;
24+
export const AI_WORKSHOP_OG_IMAGE_HEIGHT = 630;
25+
26+
export type AiWorkshopPhoto = {
27+
src: string;
28+
width: number;
29+
height: number;
30+
};
31+
32+
export type AiWorkshopOgImage = {
33+
url: string;
34+
width: number;
35+
height: number;
36+
alt: string;
37+
};
38+
39+
export type GetAiWorkshopOgImageOptions = {
40+
lang: SupportedLanguage;
41+
siteUrl: string;
42+
alt: string;
43+
};
44+
45+
export type GetAiWorkshopStructuredImagesOptions = {
46+
lang: SupportedLanguage;
47+
siteUrl: string;
48+
};
49+
50+
/**
51+
* Returns the locale-specific Open Graph image path.
52+
*
53+
* @param lang - Active locale
54+
* @returns Public path to the 1200×630 PNG for that locale
55+
*/
56+
export function getAiWorkshopOgImagePath(lang: SupportedLanguage): string {
57+
return AI_WORKSHOP_OG_IMAGE_PATHS[lang] ?? AI_WORKSHOP_OG_IMAGE_PATHS.en;
58+
}
59+
60+
/**
61+
* Returns the absolute Open Graph / Twitter image for the workshop page.
62+
*
63+
* @param options - Locale, site origin, and alt text
64+
* @returns Image object for Next.js metadata
65+
*/
66+
export function getAiWorkshopOgImage({
67+
alt,
68+
lang,
69+
siteUrl
70+
}: GetAiWorkshopOgImageOptions): AiWorkshopOgImage {
71+
return {
72+
url: `${siteUrl}${getAiWorkshopOgImagePath(lang)}`,
73+
width: AI_WORKSHOP_OG_IMAGE_WIDTH,
74+
height: AI_WORKSHOP_OG_IMAGE_HEIGHT,
75+
alt
76+
};
77+
}
78+
79+
/**
80+
* Returns absolute image URLs for JSON-LD, OG card first then workshop photos.
81+
*
82+
* @param options - Locale and site origin
83+
* @returns Ordered list of absolute image URLs
84+
*/
85+
export function getAiWorkshopStructuredImages({
86+
lang,
87+
siteUrl
88+
}: GetAiWorkshopStructuredImagesOptions): string[] {
89+
return [
90+
`${siteUrl}${getAiWorkshopOgImagePath(lang)}`,
91+
`${siteUrl}${AI_WORKSHOP_FACILITATOR_IMAGE_PATH}`,
92+
`${siteUrl}${AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH}`,
93+
`${siteUrl}${AI_WORKSHOP_ROOM_IMAGE_PATH}`
94+
];
95+
}
96+
97+
/**
98+
* Returns the facilitator photo used in the workshop hero.
99+
*/
100+
export function getAiWorkshopFacilitatorPhoto(): AiWorkshopPhoto {
101+
return {
102+
src: AI_WORKSHOP_FACILITATOR_IMAGE_PATH,
103+
width: 1024,
104+
height: 768
105+
};
106+
}
107+
108+
/**
109+
* Returns the participants photo used in the rule-of-the-room section.
110+
*/
111+
export function getAiWorkshopParticipantsPhoto(): AiWorkshopPhoto {
112+
return {
113+
src: AI_WORKSHOP_PARTICIPANTS_IMAGE_PATH,
114+
width: 1024,
115+
height: 769
116+
};
117+
}
118+
119+
/**
120+
* Returns the wide room photo used in the logistics and teaser sections.
121+
*/
122+
export function getAiWorkshopRoomPhoto(): AiWorkshopPhoto {
123+
return {
124+
src: AI_WORKSHOP_ROOM_IMAGE_PATH,
125+
width: 1024,
126+
height: 576
127+
};
128+
}

0 commit comments

Comments
 (0)