diff --git a/app/[lang]/(hyperjump)/data.ts b/app/[lang]/(hyperjump)/data.ts index 72d57e31..d62a5c01 100644 --- a/app/[lang]/(hyperjump)/data.ts +++ b/app/[lang]/(hyperjump)/data.ts @@ -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, @@ -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, @@ -39,6 +52,7 @@ import { aiWhoIsItDesc, aiWhoIsItTarget0, aiWhoIsItTarget1, + aiWhoIsItTarget2, aiWhyUsDesc, aiWhyUsReasons0, aiWhyUsReasons1, @@ -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 [ @@ -405,6 +413,7 @@ export type Service = { shortDescription: string; slug: ServiceSlug; title: string; + faqs: { question: string; answer: string }[]; }; export function services(lang: SupportedLanguage): Service[] { @@ -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), @@ -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), @@ -785,7 +802,8 @@ export function services(lang: SupportedLanguage): Service[] { category: caseStudyCtoaasMediaCategory(lang), basePath: "case-studies" } - ] + ], + faqs: [] }, { bestFor: saasBestFor(lang), @@ -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), @@ -1021,7 +1040,8 @@ export function services(lang: SupportedLanguage): Service[] { shortDescription: tddHeroDesc(lang), slug: ServiceSlug.TechDueDiligence, title: tddHeroHeading(lang), - caseStudies: [] + caseStudies: [], + faqs: [] } ]; } diff --git a/app/[lang]/(hyperjump)/services/[slug]/page.tsx b/app/[lang]/(hyperjump)/services/[slug]/page.tsx index 619cf419..91b70287 100644 --- a/app/[lang]/(hyperjump)/services/[slug]/page.tsx +++ b/app/[lang]/(hyperjump)/services/[slug]/page.tsx @@ -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"; @@ -21,7 +28,9 @@ import { servicesWhatWeDeliver, servicesWhatYouGet, servicesWhoIsItFor, - servicesWhyHyperjump + servicesWhyHyperjump, + aiFaqDesc, + aiFaqHeading } from "@/locales/.generated/strings"; import type { CaseStudy, Service } from "../../data"; @@ -91,6 +100,7 @@ export default async function ServiceDetail({ params }: ServiceDetailProps) { + @@ -517,3 +527,48 @@ function CallToAction({ lang, service }: LangProps & ServiceProps) { ); } + +function Faqs({ lang, service }: LangProps & ServiceProps) { + const { faqs } = service; + if (faqs.length === 0) return null; + + return ( +
+
+
+ + + {faqs.map(({ answer, question }, i) => ( + +
+ + {question} + + +
+ {answer} +
+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/e2e/case-studies.spec.ts b/e2e/case-studies.spec.ts index 9d745361..639dc0a2 100644 --- a/e2e/case-studies.spec.ts +++ b/e2e/case-studies.spec.ts @@ -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"; @@ -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 @@ -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", () => { diff --git a/e2e/case-study-detail.spec.ts b/e2e/case-study-detail.spec.ts index 39d6fe75..80f5f68b 100644 --- a/e2e/case-study-detail.spec.ts +++ b/e2e/case-study-detail.spec.ts @@ -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"; @@ -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 and SVG - 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 @@ -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", () => { diff --git a/e2e/job.spec.ts b/e2e/job.spec.ts index 8bb89215..6ad13389 100644 --- a/e2e/job.spec.ts +++ b/e2e/job.spec.ts @@ -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()); diff --git a/e2e/services-detail.spec.ts b/e2e/services-detail.spec.ts index 7f1c99af..7e06ef2a 100644 --- a/e2e/services-detail.spec.ts +++ b/e2e/services-detail.spec.ts @@ -1,382 +1,147 @@ -import { test, expect, Page } from "@playwright/test"; -import { ServiceSlug } from "@/app/[lang]/(hyperjump)/data"; - -// Base URL -const baseURL = "http://localhost:3000"; - -const services = Object.entries(ServiceSlug).map(([key, slug]) => ({ - title: key.replace(/([A-Z])/g, " $1").trim(), - slug -})); - -const locales = [ - ...services.map((i) => ({ - code: "en", - title: i.title, - slug: i.slug, - path: `/en/services/${i.slug}` - })), - ...services.map((i) => ({ - code: "id", - title: i.title, - slug: i.slug, - path: `/id/services/${i.slug}` - })) -] as const; - -const viewports = [ - { name: "mobile", size: { width: 360, height: 740 } }, - { name: "tablet", size: { width: 820, height: 1180 } }, - { name: "desktop", size: { width: 1280, height: 800 } }, - { 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 and SVG - 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(); +import { test, expect } from "@playwright/test"; +import { serviceBySlug, ServiceSlug } from "@/app/[lang]/(hyperjump)/data"; +import { + aiFaqHeading, + aiProductsTitle, + caseStudyButton, + servicesCaseStudies, + servicesHowItWorks, + servicesWhatWeDeliver, + servicesWhatYouGet, + servicesWhoIsItFor, + servicesWhyHyperjump +} from "@/locales/.generated/strings"; +import { supportedLanguages } from "@/locales/.generated/types"; +import { + BASE_URL, + footerTest, + gotoAndWait, + headerTest, + imagesTest, + languageSwitcherTest, + metaTest, + responsiveTest +} from "./shared-test"; + +for (const locale of supportedLanguages) { + for (const [_, slug] of Object.entries(ServiceSlug)) { + const path = `/${locale}/services/${slug}`; + const service = serviceBySlug({ lang: locale, slug }); + if (!service) { + throw new Error(`Service ${slug} not found`); } - } -} -// 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 - const header = page.locator("header, nav").first(); - return header; -} - -function getMenuNav(page: import("@playwright/test").Page) { - return page.locator('nav[aria-label="Main"]'); -} + const { + caseStudies, + content: { products }, + shortDescription, + title, + faqs + } = service; + + test.describe(`Services Detail Page - ${title}`, () => { + test.describe(`${locale.toUpperCase()} locale`, () => { + test.beforeEach(async ({ page }) => { + await gotoAndWait(page, `${BASE_URL}${path}`); + }); -function getFooter(page: import("@playwright/test").Page) { - return page.getByRole("contentinfo"); -} + test.describe("Header", headerTest(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()); + test.describe("Responsive Design", responsiveTest(path)); + test.describe("Content", async () => { + test("Hero", async ({ page }) => { + expect(page.locator("h1")).toHaveText(title); + expect(page.getByText(shortDescription)).toBeVisible(); + }); -// Utility: navigate and ensure route -async function gotoAndWait(page: import("@playwright/test").Page, url: string) { - await page.goto(url, { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL( - new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - ); // escape -} + test("Overview", async ({ page }) => { + expect(page.locator("h2").filter({ hasText: title })).toBeVisible(); + expect(page.getByTestId("request-demo-button")).toHaveCount(2); + }); -// Shared assertions for header nav links based on code in app/[lang]/(hyperjump)/components/nav.tsx -const expectedMenuPaths = (locale: string) => [ - `/${locale}/services`, - `/${locale}/products`, - `/${locale}/case-studies`, - `/${locale}#faqs` -]; + test("Who is it for", async ({ page }) => { + expect( + page.getByRole("heading", { name: servicesWhoIsItFor(locale) }) + ).toBeVisible(); + }); -// Hero and list sections selectors from page.tsx -const selectors = { - hero: "#hero", - heroHeading: - "#hero div div[dangerouslysetinnerhtml], #hero h1, #hero .text-3xl", - heroDesc: "#hero p", - cardsSection: "section div.grid", - card: "div.grid > div, div.grid > article, div.grid > li", - cardButton: "section > div a, section > div button" -}; + test("What we deliver", async ({ page }) => { + expect( + page.getByRole("heading", { + name: servicesWhatWeDeliver(locale) + }) + ).toBeVisible(); -for (const { code: locale, path, title, slug } of locales) { - test.describe("Services Detail Page", () => { - test.describe(`/${slug} -${locale.toUpperCase()} locale`, () => { - test.beforeEach(async ({ page }) => { - await gotoAndWait(page, `${baseURL}${path}`); - }); + // How it works + expect( + page.getByRole("heading", { name: servicesHowItWorks(locale) }) + ).toBeVisible(); - // 1. Navigation & Links - test.describe("Navigation & Links", () => { - test("header nav links route correctly", async ({ page }) => { - const header = getHeader(page); - await expect(header).toBeVisible(); + // What you get + expect( + page.getByRole("heading", { name: servicesWhatYouGet(locale) }) + ).toBeVisible(); - const menuNav = getMenuNav(page); - const expected = expectedMenuPaths(locale); - for (const href of expected) { - await expect( - menuNav.locator(`a[href='${href}']`).first() + // Why Hyperjump + expect( + page.getByRole("heading", { + name: servicesWhyHyperjump(locale) + }) ).toBeVisible(); - } - // Click-through checks for non-anchor-with-fragment links - for (const href of expected) { - menuNav.locator(`a[href='${href}']`).first().click(); - await expect(page).toHaveURL(`${baseURL}${href}`); - // Go back to subject page - if (href !== `/${locale}/services/${slug}`) { - await page.goBack(); - await expect(page).toHaveURL(`${baseURL}${path}`); + // Case studies + if (caseStudies.length > 0) { + expect( + page.getByRole("heading", { name: servicesCaseStudies(locale) }) + ).toBeVisible(); + for (const [ + index, + { title, slug, description } + ] of caseStudies.entries()) { + await expect(page.getByText(title)).toBeVisible(); + await expect(page.getByText(description)).toBeVisible(); + + await page + .getByRole("link", { name: caseStudyButton(locale) }) + .nth(index) + .click(); + + await page.waitForURL( + new RegExp(`/${locale}/case-studies/${slug}`) + ); + + await page.goBack(); + } } - } - }); - - test("footer links and social icons are visible and valid", async ({ - page - }) => { - const footer = getFooter(page); - await expect(footer).toBeVisible(); - const footerLinks = footer.getByRole("link"); - await expect(footerLinks.first()).toBeVisible(); - - // Ensure each link has href - const count = await footerLinks.count(); - for (let i = 0; i < count; i++) { - const link = footerLinks.nth(i); - const href = await link.getAttribute("href"); - expect(href, "footer link should have href").toBeTruthy(); - } - }); - - test("content buttons link to intended destinations", async ({ - page - }) => { - const cardLinks = page.locator(selectors.cardButton); - const n = await cardLinks.count(); - for (let i = 0; i < n; i++) { - const link = cardLinks.nth(i); - - if ( - (await link.getAttribute("data-testid")) === "request-demo-button" - ) { - continue; + // Products + if (products.length > 0) { + expect( + page.getByRole("heading", { name: aiProductsTitle(locale) }) + ).toBeVisible(); + for (const { title, description } of products) { + expect(page.getByText(title)).toBeVisible(); + expect(page.getByText(description)).toBeVisible(); + } } - await expect(link).toBeVisible(); - const href = await link.getAttribute("href"); - expect(href).toBeTruthy(); - // Only test one click to avoid navigating away multiple times unnecessarily - if (i === 0 && href) { - await link.click(); - await expect(page).toHaveURL( - new RegExp(`${href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) - ); - await page.goBack(); - await expect(page).toHaveURL(`${baseURL}${path}`); + // FAQ + if (faqs.length > 0) { + expect( + page.getByRole("heading", { name: aiFaqHeading(locale) }) + ).toBeVisible(); + for (const { question, answer } of faqs) { + expect(page.getByText(question)).toBeVisible(); + await page.getByText(question).click(); + expect(page.getByText(answer)).toBeVisible(); + } } - } - }); - }); - - // 2. Branding - test.describe("Branding", () => { - test("logo visible in header and links to home", async ({ page }) => { - const header = getHeader(page); - const logo = header - .getByRole("link") - .filter({ has: page.getByAltText("Hyperjump Logo") }) - .first(); - await expect(logo).toBeVisible(); - - const href = await logo.getAttribute("href"); - expect(href).toBe(`/${locale}`); - }); - - test("logo visible in footer and links to home", async ({ page }) => { - const footer = getFooter(page); - const logo = footer - .getByRole("link") - .filter({ has: page.getByAltText("Hyperjump Logo") }) - .first(); - await expect(logo).toBeVisible(); - const href = await logo.getAttribute("href"); - expect(href).toBe(`/${locale}`); - }); - }); - - // 3. Language Switching - test.describe("Language Switching", () => { - test("switch language to the other locale and back", async ({ - page - }) => { - const footer = getFooter(page); - const select = footer.getByRole("combobox"); - await expect(select).toBeVisible(); - - // Current value should be locale - await expect(select).toHaveValue(locale); - - const other = locale === "en" ? "id" : "en"; - await select.selectOption(other); - await page.waitForURL(new RegExp(`/(${other})/services`)); - - // Verify content changes (hero heading changes with locale) - const heading = page - .locator("#hero") - .locator( - ".text-3xl, .text-4xl, [class*='text-'][class*='font-medium']" - ) - .first(); - await expect(heading).toBeVisible(); - - // Switch back - const select2 = getFooter(page).getByRole("combobox"); - await select2.selectOption(locale); - await page.waitForURL(new RegExp(`/(${locale})/services`)); - }); - }); - - // 4. Images - test.describe("Images", () => { - test("all images load without errors", async ({ page }) => { - await expectAllImagesLoaded(page); - }); - }); - - // 5. Text & Content - test.describe("Text & Content", () => { - test("hero section visible with heading and description", async ({ - page - }) => { - const hero = page.locator(selectors.hero); - await expect(hero).toBeVisible(); - }); - - test("content sections are visible with text", async ({ page }) => { - const section = page.locator(selectors.cardsSection); - await expect(section.first()).toBeVisible(); - - const headings = section.locator("h2"); - const headingCount = await headings.count(); - expect(headingCount).toBeGreaterThan(0); - - for (let i = 0; i < headingCount; i++) { - const h2 = headings.nth(i); - await expect(h2).toBeVisible(); - await expect(h2).not.toBeEmpty(); - } - - const paragraphs = section.locator("p"); - const paragraphCount = await paragraphs.count(); - expect(paragraphCount).toBeGreaterThan(0); - - for (let i = 0; i < paragraphCount; i++) { - const p = paragraphs.nth(i); - await expect(p).toBeVisible(); - await expect(p).not.toBeEmpty(); - } - }); - - test("footer text visible", async ({ page }) => { - const footer = getFooter(page); - await expect(footer).toBeVisible(); - - const firstParagraph = footer.locator("p").first(); - await expect(firstParagraph).toBeVisible(); - await expect(firstParagraph).not.toBeEmpty(); - }); - }); - - // 6. SEO & Metadata - test.describe("SEO & Metadata", () => { - test("meta title and description are set correctly", async ({ - page - }) => { - const title = await page.title(); - expect(title).toMatch(title); - - const metaDesc = await page - .locator('head meta[name="description"]') - .getAttribute("content"); - expect(metaDesc).toBeTruthy(); - // Basic sanity: should include hero description text excerpt - const bodyText = await page.locator("body").innerText(); - expect(bodyText.length).toBeGreaterThan(50); - }); - }); - - // 7. Test Structure by UI sections - test.describe("Sections", () => { - test("Header", async ({ page }) => { - const header = getHeader(page); - await expect(header).toBeVisible(); - }); - - test("Hero Section", async ({ page }) => { - const hero = page.locator(selectors.hero); - await expect(hero).toBeVisible(); - }); - - test("Services Section", async ({ page }) => { - const grid = page.locator("section div.grid"); - await expect(grid.first()).toBeVisible(); - - const itemCount = await grid - .first() - .locator("div, article, li") - .count(); - expect(itemCount).toBeGreaterThan(0); - }); - - test("Footer", async ({ page }) => { - const footer = getFooter(page); - await expect(footer).toBeVisible(); - }); - }); - - // 8. Responsive Design - test.describe("Responsive Design", () => { - for (const viewport of viewports) { - test(`layout renders correctly at ${viewport.name} (${viewport.size.width}x${viewport.size.height})`, async ({ - browser - }) => { - const context = await browser.newContext({ - viewport: viewport.size - }); - const page = await context.newPage(); - await page.goto(`${baseURL}${path}`, { - waitUntil: "domcontentloaded" - }); - - const header = getHeader(page); - await expect(header).toBeVisible(); - - const hero = page.locator(selectors.hero); - await expect(hero).toBeVisible(); - - const grid = page.locator("section div.grid").first(); - await expect(grid).toBeVisible(); - - const footer = getFooter(page); - await expect(footer).toBeVisible(); - - await context.close(); }); - } + }); }); }); - }); + } } diff --git a/e2e/services.spec.ts b/e2e/services.spec.ts index 1d9bf4c1..285a2426 100644 --- a/e2e/services.spec.ts +++ b/e2e/services.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { imagesTest } from "./shared-test"; // Base URL const baseURL = "http://localhost:3000"; @@ -14,44 +15,6 @@ const viewports = [ { name: "large", size: { width: 1536, height: 960 } } ] as const; -async function expectAllImagesLoaded(page: any) { - // 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, image, [style*="background-image"]'); - const count = await images.count(); - - for (let i = 0; i < count; i++) { - const el = images.nth(i); - const tag = await el.evaluate((n: any) => n.tagName.toLowerCase()); - - await el.scrollIntoViewIfNeeded(); - await expect(el).toBeVisible(); - - if (tag === "img" || tag === "image") { - const ok = await el.evaluate((node: any) => { - // @ts-ignore - const img = node; - const nw = img.naturalWidth ?? 1; - const nh = img.naturalHeight ?? 1; - const isSVG = !!img.href?.baseVal; - - // Allow lazy images that are replaced with placeholder - const isLoaded = (nw > 0 && nh > 0) || isSVG; - const src = img.currentSrc || img.src || img.href?.baseVal || "(none)"; - - return { isLoaded, src }; - }); - - expect( - ok.isLoaded, - `Image failed to load or has zero size: ${ok.src}` - ).toBeTruthy(); - } - } -} - // Utility: get nav and footer link locators function getHeader(page: import("@playwright/test").Page) { const header = page.locator("header, nav").first(); @@ -217,11 +180,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", () => { diff --git a/e2e/shared-test.ts b/e2e/shared-test.ts index d769ef55..e472c6a1 100644 --- a/e2e/shared-test.ts +++ b/e2e/shared-test.ts @@ -13,7 +13,7 @@ export async function gotoAndWait(page: Page, url: string) { export function metaTest() { return () => { - test("Header", async ({ page }) => { + test("Meta title and description should exist", async ({ page }) => { const title = await page.title(); const description = await page .locator('meta[name="description"]') @@ -96,25 +96,29 @@ export function imagesTest() { } 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, image, [style*="background-image"]'); + const main = page.locator("main"); + const images = main.locator('img, image, [style*="background-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: any) => n.tagName.toLowerCase()); - await el.scrollIntoViewIfNeeded(); - await expect(el).toBeVisible(); - if (tag === "img" || tag === "image") { - const ok = await el.evaluate((node: any) => { - const img = node; + await el.scrollIntoViewIfNeeded(); + await expect(el, `Image #${i} not visible`).toBeVisible(); + + const { isLoaded, src } = await el.evaluate((img: any) => { const nw = img.naturalWidth ?? 1; const nh = img.naturalHeight ?? 1; - const isSVG = !!img.href?.baseVal; + const isSVG = img.href?.baseVal; // Allow lazy images that are replaced with placeholder const isLoaded = (nw > 0 && nh > 0) || isSVG; @@ -124,14 +128,14 @@ async function expectAllImagesLoaded(page: Page) { }); expect( - ok.isLoaded, - `Image failed to load or has zero size: ${ok.src}` + isLoaded, + `Image failed to load or has zero size: ${src}` ).toBeTruthy(); } } } -export function languageSwitcherTest(locale: SupportedLanguage, path: string) { +export function languageSwitcherTest(locale: SupportedLanguage) { return () => { test("switch language to the other locale and back", async ({ page }) => { const footer = getFooter(page); diff --git a/locales/en/ai.json b/locales/en/ai.json index f3362c0b..2ac7d917 100644 --- a/locales/en/ai.json +++ b/locales/en/ai.json @@ -102,5 +102,20 @@ "products": { "title": "Our Products", "description": "Innovative products tailored to support your goals and scale with your needs." + }, + "faq": { + "heading": "Frequently asked questions", + "desc": "Everything you need to go from concept to fully deployed Al agent-done for you, end to end.", + + "0_question": "What kinds of problems can a custom AI agent solve?", + "0_answer": "AI agents can handle everything from answering customer inquiries, managing internal workflows, extracting insights from documents, to automating tasks across apps like Slack, CRMs, and databases. If it's repetitive, time-consuming, or data-driven. An agent can likely help.", + "1_question": "How long does the process take?", + "1_answer": "Most clients go from strategy to launch in 10-14 business days.", + "2_question": "Do I need to know anything about AI?", + "2_answer": "Nope. We handle all the technical work. Just tell us what you want the agent to do.", + "3_question": "What kind of tools can it integrate with?", + "3_answer": "Slack, Notion, Google Workspace, CRMs, Zapier, APIs, internal databases, you name it.", + "5_question": "Can the AI use our internal data?", + "5_answer": "Yes. We use Retrieval-Augmented Generation (RAG) to let your agent access and reason over your internal documents, knowledge bases, and systems. Securely and with up-to-date context." } } diff --git a/locales/id/ai.json b/locales/id/ai.json index 54e35a1a..266f8193 100644 --- a/locales/id/ai.json +++ b/locales/id/ai.json @@ -102,5 +102,20 @@ "products": { "title": "Produk Kami", "description": "Produk inovatif yang dirancang untuk mendukung tujuan Anda dan berkembang sesuai kebutuhan Anda." + }, + "faq": { + "heading": "Pertanyaan yang Sering Diajukan", + "desc": "Segala hal yang Anda butuhkan untuk mewujudkan agen AI dari konsep hingga implementasi penuh dikerjakan secara menyeluruh oleh kami.", + + "0_question": "Masalah apa saja yang bisa diselesaikan agen AI kustom?", + "0_answer": "Agen AI dapat menangani berbagai hal mulai dari menjawab pertanyaan pelanggan, mengelola alur kerja internal, mengekstraksi wawasan dari dokumen, hingga mengotomatisasi tugas antar aplikasi seperti Slack, CRM, dan basis data. Jika itu repetitif, memakan waktu, atau berbasis data agen AI bisa membantu.", + "1_question": "Berapa lama prosesnya?", + "1_answer": "Sebagian besar klien kami menjalani proses dari strategi hingga peluncuran dalam 10-14 hari kerja.", + "2_question": "Apakah saya harus memahami AI?", + "2_answer": "Tidak perlu. Kami menangani seluruh aspek teknisnya Anda cukup menjelaskan apa yang ingin dilakukan oleh agen tersebut.", + "3_question": "Alat apa saja yang bisa diintegrasikan?", + "3_answer": "Slack, Notion, Google Workspace, CRM, Zapier, API, basis data internal apa pun bisa diintegrasikan.", + "5_question": "Apakah AI bisa menggunakan data internal kami?", + "5_answer": "Bisa. Kami menggunakan Retrieval-Augmented Generation (RAG) agar agen Anda dapat mengakses dan memahami dokumen internal, basis pengetahuan, dan sistem Anda secara aman dan kontekstual." } }