Skip to content
48 changes: 34 additions & 14 deletions app/[lang]/(hyperjump)/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import type { ReactNode } from "react";
import type { SupportedLanguage } from "@/locales/.generated/types";
import {
aiBestFor,
aiWhatIsDesc,
aiDescription,
aiFaq0Answer,
aiFaq0Question,
aiFaq1Answer,
aiFaq1Question,
aiFaq2Answer,
aiFaq2Question,
aiFaq3Answer,
aiFaq3Question,
aiFaq5Answer,
aiFaq5Question,
aiHeroDesc,
aiHeroHeading,
aiHowItWorksDesc,
Expand All @@ -17,18 +26,22 @@ import {
aiHowItWorksStep3Title,
aiHowItWorksStep4Text,
aiHowItWorksStep4Title,
aiWhatIsDesc,
aiWhatIsHighlight,
aiWhatWeDeliverCard0Items0,
aiWhatWeDeliverCard0Items1,
aiWhatWeDeliverCard0Items2,
aiWhatWeDeliverCard0Text,
aiWhatWeDeliverCard0Title,
aiWhatWeDeliverCard1Items0,
aiWhatWeDeliverCard1Items1,
aiWhatWeDeliverCard1Items2,
aiWhatWeDeliverCard1Text,
aiWhatWeDeliverCard1Title,
aiWhatWeDeliverCard2Items0,
aiWhatWeDeliverCard2Items1,
aiWhatWeDeliverCard2Items2,
aiWhatWeDeliverCard2Text,
aiWhatWeDeliverCard2Title,
aiWhatWeDeliverDesc,
aiWhatYouGetDesc,
Expand All @@ -39,6 +52,7 @@ import {
aiWhoIsItDesc,
aiWhoIsItTarget0,
aiWhoIsItTarget1,
aiWhoIsItTarget2,
aiWhyUsDesc,
aiWhyUsReasons0,
aiWhyUsReasons1,
Expand Down Expand Up @@ -259,14 +273,8 @@ import {
tddWhyUsDesc,
tddWhyUsReasons0,
tddWhyUsReasons1,
tddWhyUsReasons2,
aiWhoIsItTarget2,
startGptHeroDesc,
aiWhatWeDeliverCard0Title,
aiWhatWeDeliverCard1Text,
aiWhatWeDeliverCard2Text,
mediaPulseHeroDesc
} from "@/locales/.generated/server";
tddWhyUsReasons2
} from "@/locales/.generated/strings";

export function getCaseStudies(lang: SupportedLanguage) {
return [
Expand Down Expand Up @@ -405,6 +413,7 @@ export type Service = {
shortDescription: string;
slug: ServiceSlug;
title: string;
faqs: { question: string; answer: string }[];
};

export function services(lang: SupportedLanguage): Service[] {
Expand Down Expand Up @@ -517,7 +526,14 @@ export function services(lang: SupportedLanguage): Service[] {
shortDescription: aiHeroDesc(lang),
slug: ServiceSlug.InferenceAI,
title: aiHeroHeading(lang),
caseStudies: []
caseStudies: [],
faqs: [
{ question: aiFaq0Question(lang), answer: aiFaq0Answer(lang) },
{ question: aiFaq1Question(lang), answer: aiFaq1Answer(lang) },
{ question: aiFaq2Question(lang), answer: aiFaq2Answer(lang) },
{ question: aiFaq3Question(lang), answer: aiFaq3Answer(lang) },
{ question: aiFaq5Question(lang), answer: aiFaq5Answer(lang) }
]
},
{
bestFor: erpBestFor(lang),
Expand Down Expand Up @@ -631,7 +647,8 @@ export function services(lang: SupportedLanguage): Service[] {
shortDescription: erpHeroDesc(lang),
slug: ServiceSlug.ErpImplementation,
title: erpHeroHeading(lang),
caseStudies: []
caseStudies: [],
faqs: []
},
{
bestFor: ctoaasBestFor(lang),
Expand Down Expand Up @@ -785,7 +802,8 @@ export function services(lang: SupportedLanguage): Service[] {
category: caseStudyCtoaasMediaCategory(lang),
basePath: "case-studies"
}
]
],
faqs: []
},
{
bestFor: saasBestFor(lang),
Expand Down Expand Up @@ -891,7 +909,8 @@ export function services(lang: SupportedLanguage): Service[] {
shortDescription: saasHeroDesc(lang),
slug: ServiceSlug.SoftwareAsAService,
title: saasHeroHeading(lang),
caseStudies: []
caseStudies: [],
faqs: []
},
{
bestFor: tddBestFor(lang),
Expand Down Expand Up @@ -1021,7 +1040,8 @@ export function services(lang: SupportedLanguage): Service[] {
shortDescription: tddHeroDesc(lang),
slug: ServiceSlug.TechDueDiligence,
title: tddHeroHeading(lang),
caseStudies: []
caseStudies: [],
faqs: []
}
];
}
Expand Down
57 changes: 56 additions & 1 deletion app/[lang]/(hyperjump)/services/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import Link from "next/link";
import { notFound } from "next/navigation";

import ButtonCTA from "@/app/components/cta-button";
import { GridItemsTitle } from "@/app/components/grid-items";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from "@/components/ui/accordion";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import data from "@/data.json";
Expand All @@ -21,7 +28,9 @@ import {
servicesWhatWeDeliver,
servicesWhatYouGet,
servicesWhoIsItFor,
servicesWhyHyperjump
servicesWhyHyperjump,
aiFaqDesc,
aiFaqHeading
} from "@/locales/.generated/strings";

import type { CaseStudy, Service } from "../../data";
Expand Down Expand Up @@ -91,6 +100,7 @@ export default async function ServiceDetail({ params }: ServiceDetailProps) {
<HowItWorks lang={lang} service={service} />
<WhatYouGet lang={lang} service={service} />
<WhyUs lang={lang} service={service} />
<Faqs lang={lang} service={service} />
<Recommendation caseStudies={service.caseStudies} lang={lang} />
<CallToAction lang={lang} service={service} />
</>
Expand Down Expand Up @@ -517,3 +527,48 @@ function CallToAction({ lang, service }: LangProps & ServiceProps) {
</section>
);
}

function Faqs({ lang, service }: LangProps & ServiceProps) {
const { faqs } = service;
if (faqs.length === 0) return null;

return (
<section
id="faqs"
data-testid="faq-section"
className="flex bg-white px-4 py-8 md:px-20 md:py-16">
<div className="mx-auto flex w-full max-w-5xl flex-col items-start gap-6 md:flex-row-reverse">
<div className="w-full">
<GridItemsTitle
title={aiFaqHeading(lang)}
description={aiFaqDesc(lang)}
layout="vertical"
descriptionClassname="w-full md:max-w-2xl"
/>
<Accordion
type="single"
collapsible
className="mx-auto w-full max-w-5xl space-y-4"
data-testid="faq-accordion">
{faqs.map(({ answer, question }, i) => (
<AccordionItem key={i} value={`faq-${i}`} asChild>
<div className="w-full rounded-[6px] border border-gray-200 bg-white px-6 py-2 shadow-xs transition-all duration-300">
<AccordionTrigger
className="flex w-full items-center justify-between gap-2 text-left text-xl font-medium text-[#020F15] no-underline transition hover:no-underline focus:no-underline"
data-testid={`faq-trigger-${i}`}>
{question}
</AccordionTrigger>
<AccordionContent asChild>
<div className="flex flex-col gap-4 px-0 text-lg text-[#73767E] lg:text-lg">
{answer}
</div>
</AccordionContent>
</div>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</section>
);
}
64 changes: 3 additions & 61 deletions e2e/case-studies.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
import { imagesTest } from "./shared-test";

// Base URL
const baseURL = "http://localhost:3000";
Expand All @@ -16,61 +17,6 @@ const viewports = [
{ name: "large", size: { width: 1536, height: 960 } }
] as const;

// Utility: assert all images load (no broken images)
async function expectAllImagesLoaded(page: Page) {
// Scroll to bottom to trigger lazy-loaded images
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState("networkidle");

const images = page.locator('img, [style*="background-image"], image');
const count = await images.count();

for (let i = 0; i < count; i++) {
const el = images.nth(i);
const tag = await el.evaluate((n) => n.tagName.toLowerCase());

if (tag === "img" || tag === "image") {
// Scroll element into view to ensure it loads
await el.scrollIntoViewIfNeeded();
await expect(el).toBeVisible();

// Check if image loaded successfully
const result = await el.evaluate(
(img: HTMLImageElement | SVGImageElement) => {
const nw = (img as HTMLImageElement).naturalWidth;
const nh = (img as HTMLImageElement).naturalHeight;
const hasSvgHref = !!(img as SVGImageElement).href?.baseVal;
const src =
(img as HTMLImageElement).currentSrc ||
(img as HTMLImageElement).src ||
"";

// Image is considered loaded if:
// 1. It has naturalWidth/Height > 0 (regular images)
// 2. It's an SVG with href (SVG image elements)
// 3. naturalWidth/Height are undefined (SVG elements without these props)
const isLoaded =
(typeof nw === "number" &&
typeof nh === "number" &&
nw > 0 &&
nh > 0) ||
hasSvgHref ||
(typeof nw === "undefined" && typeof nh === "undefined");

return { isLoaded, src };
}
);

expect(
result.isLoaded,
`Image failed to load or has zero size: ${result.src}`
).toBe(true);
} else {
await expect(el).toBeVisible();
}
}
}

// Utility: get nav and footer link locators
function getHeader(page: Page) {
// Header is sticky nav; fall back to first header/nav region
Expand Down Expand Up @@ -247,11 +193,7 @@ for (const { code: locale, path } of locales) {
});

// 4. Images
test.describe("Images", () => {
test("all images load without errors", async ({ page }) => {
await expectAllImagesLoaded(page);
});
});
test.describe("Images", imagesTest());

// 5. Text & Content
test.describe("Text & Content", () => {
Expand Down
44 changes: 2 additions & 42 deletions e2e/case-study-detail.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect, Page } from "@playwright/test";
import { getCaseStudies } from "@/app/[lang]/(hyperjump)/case-studies/data";
import { imagesTest } from "./shared-test";

// Base URL
const baseURL = "http://localhost:3000";
Expand Down Expand Up @@ -27,43 +28,6 @@ const viewports = [
{ name: "large", size: { width: 1536, height: 960 } }
] as const;

// Utility: assert all images load (no broken images)
export async function expectAllImagesLoaded(page: Page) {
// Scroll to bottom to trigger lazy-loaded images
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState("networkidle");
const main = page.locator("main");
const images = main.locator('img, [style*="background-image"], image');
const count = await images.count();

if (count === 0) return;

for (let i = 0; i < count; i++) {
const el = images.nth(i);
const tag = await el.evaluate((n) => n.tagName.toLowerCase());

await expect(el, `Image #${i} not visible`).toBeVisible({ timeout: 5000 });

if (tag === "img" || tag === "image") {
const ok = await el.evaluate(
(img: HTMLImageElement | SVGImageElement) => {
// @ts-ignore — handle both <img> and SVG <image>
const nw = (img as any).naturalWidth ?? 1;
// @ts-ignore
const nh = (img as any).naturalHeight ?? 1;
// @ts-ignore
const href = (img as any).href?.baseVal ?? (img as any).src ?? null;
return (nw > 0 && nh > 0) || Boolean(href);
}
);
expect(
ok,
`Image #${i} failed to load or has 0x0 natural size`
).toBeTruthy();
}
}
}

// Utility: get nav and footer link locators
function getHeader(page: import("@playwright/test").Page) {
// Header is sticky nav; fall back to first header/nav region
Expand Down Expand Up @@ -240,11 +204,7 @@ for (const { code: locale, path, title, slug } of locales) {
});

// 4. Images
test.describe("Images", () => {
test("all images load without errors", async ({ page }) => {
await expectAllImagesLoaded(page);
});
});
test.describe("Images", imagesTest());

// 5. Text & Content
test.describe("Text & Content", () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/job.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ for (const locale of supportedLanguages) {
await gotoAndWait(page, `${BASE_URL}${path}`);
});
test.describe("Header", headerTest(locale, path));
test.describe("Language Switching", languageSwitcherTest(locale, path));
test.describe("Language Switching", languageSwitcherTest(locale));
test.describe("Images", imagesTest());
test.describe("Footer", footerTest(locale));
test.describe("Meta title and description should exist", metaTest());
Expand Down
Loading