diff --git a/.env.example b/.env.example index 8d201fd138..2fb4fbdc1a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ NEXT_PUBLIC_GITHUB_SHA=0000000000000000000000000000000000000000 # Current site URL SITE_URL=*** +#JOTFORM API KEY +JOTFORM_API_KEY=*** + # Recaptcha Keys GOOGLE_RECAPTCHA_KEY=*** diff --git a/.gitignore b/.gitignore index 6aac4a0719..72cd9fdbd5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ public/admin/.gitignore # testing /coverage +# playwright-mcp scratch output (snapshots, console logs, screenshots) +.playwright-mcp/ + # next.js /.next/ /out/ diff --git a/app/api/lead-capture/route.ts b/app/api/lead-capture/route.ts new file mode 100644 index 0000000000..e271021f12 --- /dev/null +++ b/app/api/lead-capture/route.ts @@ -0,0 +1,73 @@ +import { NextRequest } from "next/server"; + +/** + * Receives lead-capture answers from the V3LeadCapture block and forwards them + * to JotForm's submissions API, keeping the API key server-side. + * + * Body: { jotFormId: string, fields: Record } + * Each `fields` key is a JotForm question id (qid); JotForm expects them encoded + * as `submission[{qid}]=value`. An array value is a multi-option field, encoded + * as `submission[{qid}][{index}]=value` (e.g. location → country + state). + */ +export async function POST(request: NextRequest) { + try { + const apiKey = process.env.JOTFORM_API_KEY; + if (!apiKey) { + return Response.json( + { message: "JotForm is not configured." }, + { status: 500 } + ); + } + + const { jotFormId, fields } = await request.json(); + + if (!jotFormId || !fields || typeof fields !== "object") { + return Response.json( + { message: "Missing jotFormId or fields." }, + { status: 400 } + ); + } + + const body = new URLSearchParams(); + for (const [qid, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry != null && entry !== "") { + body.append(`submission[${qid}][${index}]`, String(entry)); + } + }); + } else if (value != null && value !== "") { + body.append(`submission[${qid}]`, String(value)); + } + } + + const res = await fetch( + `https://api.jotform.com/form/${encodeURIComponent( + jotFormId + )}/submissions?apiKey=${encodeURIComponent(apiKey)}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + } + ); + + if (!res.ok) { + const detail = await res.text(); + console.error("JotForm submission failed:", res.status, detail); + return Response.json( + { message: "Failed to submit lead." }, + { status: 502 } + ); + } + + return Response.json({ ok: true }, { status: 200 }); + } catch (error) { + console.error("lead-capture error:", error); + return Response.json({ message: "Unexpected error." }, { status: 500 }); + } +} + +export async function GET() { + return Response.json({ message: "Unsupported method" }, { status: 405 }); +} diff --git a/app/consulting/[filename]/page.tsx b/app/consulting/[filename]/page.tsx index 4be476d64e..0ed0b91cb9 100644 --- a/app/consulting/[filename]/page.tsx +++ b/app/consulting/[filename]/page.tsx @@ -161,7 +161,7 @@ export async function generateMetadata( const seo = newPage?.props?.data?.consultingv2?.seo || - oldPage.props?.data?.consulting?.seo; + oldPage?.props?.data?.consulting?.seo; const headerUrl = newPage?.props?.header?.url || oldPage?.props?.header?.url; if (seo && !seo.canonical) { diff --git a/app/consulting/index.tsx b/app/consulting/index.tsx index 918a43f688..e1dc1b4cc8 100644 --- a/app/consulting/index.tsx +++ b/app/consulting/index.tsx @@ -23,6 +23,11 @@ export default function ConsultingIndex({ tinaProps }) { const categories = useMemo(() => { return node.categories.reduce((acc, curr) => { const mappedPages = curr.pages.reduce((pageAcc, p) => { + // Skip entries with neither an external URL nor a linked page — + // otherwise reading p.page.id below crashes the build prerender. + if (!p.externalUrl && !p.page?.id) { + return pageAcc; + } const mappedPage = { url: p.externalUrl || diff --git a/components/blocks-renderer.tsx b/components/blocks-renderer.tsx index 10a21fea6a..7cc9beeb62 100644 --- a/components/blocks-renderer.tsx +++ b/components/blocks-renderer.tsx @@ -160,6 +160,62 @@ const Spacer = dynamic(() => import("./blocks/spacer/spacer").then((mod) => mod.Spacer) ); +const V3Hero = dynamic(() => + import("./blocks/v3/hero/hero").then((mod) => mod.V3Hero) +); + +const V3LogoCarousel = dynamic(() => + import("./blocks/v3/logoCarousel/logoCarousel").then( + (mod) => mod.V3LogoCarousel + ) +); + +const V3FeatureSteps = dynamic(() => + import("./blocks/v3/featureSteps/featureSteps").then( + (mod) => mod.V3FeatureSteps + ) +); + +const V3Process = dynamic(() => + import("./blocks/v3/process/process").then((mod) => mod.V3Process) +); + +const V3Statistics = dynamic(() => import("./blocks/v3/statistics/statistics")); + +const V3Cta = dynamic(() => + import("./blocks/v3/cta/cta").then((mod) => mod.V3Cta) +); + +const V3Testimonials = dynamic(() => + import("./blocks/v3/testimonials/testimonials").then( + (mod) => mod.V3Testimonials + ) +); + +const V3StackCards = dynamic(() => + import("./blocks/v3/stackCards/stackCards").then((mod) => mod.V3StackCards) +); + +const V3Faq = dynamic(() => + import("./blocks/v3/faq/faq").then((mod) => mod.V3Faq) +); + +const V3LeadCapture = dynamic(() => + import("./blocks/v3/leadCapture/leadCapture").then((mod) => mod.V3LeadCapture) +); + +const V3VideoHighlights = dynamic(() => + import("./blocks/v3/videoHighlights/videoHighlights").then( + (mod) => mod.V3VideoHighlights + ) +); + +const V3CardCarousel = dynamic(() => + import("./blocks/v3/cardCarousel/cardCarousel").then( + (mod) => mod.V3CardCarousel + ) +); + const componentMap = { AboutUs, Carousel, @@ -198,6 +254,18 @@ const componentMap = { TechnologyCardCarousel, Spacer, UtilityButton, + V3Hero, + V3LogoCarousel, + V3FeatureSteps, + V3Process, + V3Statistics, + V3Cta, + V3Testimonials, + V3StackCards, + V3Faq, + V3LeadCapture, + V3VideoHighlights, + V3CardCarousel, }; export const Blocks = ({ prefix, blocks }) => { diff --git a/components/blocks/index.ts b/components/blocks/index.ts index 74f4d8adce..9dd8d9173a 100644 --- a/components/blocks/index.ts +++ b/components/blocks/index.ts @@ -54,12 +54,36 @@ import { SpacerSchema } from "./spacer/spacer.schema"; import { tableBlockSchema } from "./tableLayout.schema"; import { testimonialsListSchema } from "./testimonialsList"; import { upcomingEventsBlockSchema } from "./upcomingEvents"; +import { V3FeatureStepsSchema } from "./v3/featureSteps/featureSteps.schema"; +import { V3HeroSchema } from "./v3/hero/hero.schema"; +import { V3ProcessSchema } from "./v3/process/process.schema"; +import { V3StatisticsTemplate } from "./v3/statistics/statistics.schema"; +import { V3CtaSchema } from "./v3/cta/cta.schema"; +import { V3LogoCarouselSchema } from "./v3/logoCarousel/logoCarousel.schema"; +import { V3TestimonialsSchema } from "./v3/testimonials/testimonials.schema"; +import { V3StackCardsSchema } from "./v3/stackCards/stackCards.schema"; +import { V3FaqSchema } from "./v3/faq/faq.schema"; +import { V3LeadCaptureSchema } from "./v3/leadCapture/leadCapture.template"; +import { V3VideoHighlightsSchema } from "./v3/videoHighlights/videoHighlights.schema"; +import { V3CardCarouselSchema } from "./v3/cardCarousel/cardCarousel.schema"; import { verticalImageLayoutBlockSchema } from "./verticalImageLayout"; import { verticalListItemSchema } from "./verticalListItem"; import { videoEmbedBlockSchema } from "./videoEmbed.schema"; //NOTE: this is the order that blocks will appear in the Tina Editor export const pageBlocks: Template[] = [ + V3HeroSchema, + V3LogoCarouselSchema, + V3FeatureStepsSchema, + V3ProcessSchema, + V3StatisticsTemplate, + V3CtaSchema, + V3TestimonialsSchema, + V3StackCardsSchema, + V3FaqSchema, + V3LeadCaptureSchema, + V3VideoHighlightsSchema, + V3CardCarouselSchema, BreadcrumbSchema, ImageTextBlockSchema, LogoCarouselSchema, diff --git a/components/blocks/v3/cardCarousel/cardCarousel.schema.tsx b/components/blocks/v3/cardCarousel/cardCarousel.schema.tsx new file mode 100644 index 0000000000..b0e6934794 --- /dev/null +++ b/components/blocks/v3/cardCarousel/cardCarousel.schema.tsx @@ -0,0 +1,299 @@ +import { default as React, useEffect, useState } from "react"; +import { wrapFieldsWithMeta } from "tinacms"; +import type { Template, TinaField } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import tabletTextAlignmentField from "../../../blocksSubtemplates/tabletTextAlignment.schema"; +import { buttonSchema } from "../../../button/templateButton.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; +import { mediaTypeField } from "../../mediaType.schema"; +import { youtubeEmbedField } from "../../youtubeEmbed.schema"; +import { Checkbox } from "../../../ui/checkbox"; + +const GUIDFunction = () => Math.random().toString(36).substring(7); + +const GUIDGeneratorComponent = (props) => { + useEffect(() => { + if (!props.input.value) { + props.input.onChange(GUIDFunction()); + } + }); + return <>; +}; + +const defaultCardItem = { + guid: null, + mediaType: "youtube", + altText: "Lorem Ipsum", + category: "Lorem", + heading: "Lorem Ipsum", +}; + +export const V3CardCarouselSchema: Template = { + name: "v3CardCarousel", + label: " Card Carousel", + ui: { + defaultItem: { + buttons: [ + { + buttonText: "Lorem", + colour: 0, + }, + { + buttonText: "Ipsum", + colour: 1, + }, + ], + isStacked: false, + brow: "Our work", + heading: "Lorem **Ipsum**", + isH1: false, + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + categoryGroup: [ + { + categoryName: "Lorem", + cardGuidList: { + cardGuidList: [], + }, + }, + { + categoryName: "Dolor", + cardGuidList: { + cardGuidList: [], + }, + }, + ], + cards: [ + defaultCardItem, + defaultCardItem, + defaultCardItem, + defaultCardItem, + defaultCardItem, + ], + }, + }, + fields: [ + { + type: "boolean", + label: "Stacked Mode", + name: "isStacked", + description: "Remove the carousel effect and stack card entries.", + }, + { + type: "string", + label: "Brow", + name: "brow", + description: "Optional small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "boolean", + label: "Use as H1", + name: "isH1", + description: "Choose to use the heading as an H1 instead of an H2.", + }, + { + type: "string", + label: "Body", + name: "body", + description: "Flavour text under the block title.", + ui: { + component: "textarea", + }, + }, + tabletTextAlignmentField as TinaField, + { + name: "buttons", + label: "Button Row", + type: "object", + list: true, + description: "A row of buttons. Max 2.", + ui: { + defaultItem: { + buttonText: "Lorem", + }, + max: 2, + itemProps(item) { + return { label: `${item.buttonText}` }; + }, + }, + //@ts-expect-error – fields are not being recognized + fields: buttonSchema, + }, + { + type: "object", + label: "Category Group", + name: "categoryGroup", + list: true, + description: + "Tabs that group cards, under the hood cards and tabs are linked by hidden GUIDs.", + ui: { + defaultItem: { + categoryName: "Lorem Ipsum", + cardGuidList: { + cardGuidList: [], + }, + }, + + itemProps: (item) => { + return { label: item?.categoryName ?? "Tab" }; + }, + }, + fields: [ + { + type: "string", + label: "Category Name", + name: "categoryName", + description: "Text to include on the tab.", + }, + //This is a little convoluted, due to the way Tina limits data passing between components + //We can get all form values from the custom component, but still need to identify the correct block + //The hidden GUIDs let us find the correct block, category, and card list to use. + { + type: "object", + label: "Attached Cards", + name: "cardGuidList", + fields: [ + { + type: "string", + label: "Category GUID", + name: "guid", + }, + { + type: "string", + label: "Card GUID List", + name: "cardGuidList", + list: true, + }, + ], + ui: { + component: wrapFieldsWithMeta(({ form, input }) => { + const formState = form.getState(); + const [fieldValues, setFieldValues] = useState( + input.value?.cardGuidList ?? [] + ); + const [options, setOptions] = useState([]); + const cardCarouselBlocks = + formState.values.blocks?.filter( + (block) => block._template === "v3CardCarousel" + ) ?? []; + useEffect(() => { + if (!input.value?.guid) { + input.onChange({ + guid: GUIDFunction(), + cardGuidList: [], + }); + } + cardCarouselBlocks.forEach((block) => { + block.categoryGroup?.forEach((category) => { + if (category.cardGuidList?.guid === input.value?.guid) { + setOptions(block.cards ?? []); + } + }); + }); + }); + + return ( +
+
+ {options.length === 0 &&

No cards found.

} + {options?.map((item, index) => { + return ( +
+ { + const newFieldValues = checked + ? [...fieldValues, item.guid] + : fieldValues.filter( + (value) => value !== item.guid + ); + const newObjectValue = { + ...input.value, + cardGuidList: newFieldValues, + }; + setFieldValues(newFieldValues); + return input.onChange(newObjectValue); + }} + /> + + {!item.guid && ( +

+ ⚠️ Make and save changes to this card before + assigning it to a tab ⚠️ +

+ )} +
+ ); + })} +
+
+ ); + }), + }, + }, + ], + }, + { + type: "object", + label: "Cards", + name: "cards", + description: "The list of cards to be displayed.", + list: true, + ui: { + itemProps: (item) => { + return { label: item?.heading ?? "Card" }; + }, + defaultItem: defaultCardItem, + }, + fields: [ + { + type: "string", + label: "guid", + name: "guid", + ui: { + component: GUIDGeneratorComponent, + }, + }, + // @ts-expect-error – Tina doen't reconize imported fields + mediaTypeField, + // @ts-expect-error – Tina doen't reconize imported fields + youtubeEmbedField, + { + type: "image", + label: "Image", + name: "image", + description: "Image source for the card.", + }, + { + type: "string", + label: "Alt Text", + name: "altText", + description: "Alternative text for the card.", + }, + { + type: "string", + label: "Category", + name: "category", + description: "Single pill shown above the heading.", + }, + { + type: "string", + label: "Heading", + name: "heading", + }, + ], + }, + //@ts-expect-error – fields are not being recognized + backgroundSchema, + ], +}; diff --git a/components/blocks/v3/cardCarousel/cardCarousel.tsx b/components/blocks/v3/cardCarousel/cardCarousel.tsx new file mode 100644 index 0000000000..908568fce8 --- /dev/null +++ b/components/blocks/v3/cardCarousel/cardCarousel.tsx @@ -0,0 +1,248 @@ +"use client"; + +import AlternatingText from "@/components/alternating-text"; +import ButtonRow from "@/components/blocksSubtemplates/buttonRow"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselPickItem, + useCarousel, +} from "@/components/ui/carousel"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import { VideoModal } from "@/components/videoModal"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { tinaField } from "tinacms/dist/react"; +// Reuse the existing GUID-linked tab logic; only the tab styling differs in v3. +import { + Tabs, + useTabCarousel, +} from "../../cardCarousel/layout/cardCarouselTabs"; + +type V3CardProps = { + data; + placeholder: boolean; + className?: string; +}; + +// v3 card: dark surface, soft border, consistent rounded-2xl styling. +const V3Card = ({ data, placeholder, className }: V3CardProps) => { + const isYoutubeEmbed = data.mediaType === "youtube"; + const youtubeUrl = data.youtubeUrl; + const [usePlaceholder, setUsePlaceholder] = useState(false); + const placeholderImage = "/images/videoPlaceholder.png"; + + return ( +
+ {youtubeUrl && isYoutubeEmbed ? ( +
+ +
+ ) : ( + (data.image || placeholder) && ( +
+ setUsePlaceholder(true)} + alt={data.altText ?? "Card Image"} + fill={true} + className={data.contain ? "object-contain" : "object-cover"} + /> +
+ ) + )} +
+ {data.category && ( + + {data.category} + + )} + {data.heading && ( +

+ {data.heading} +

+ )} +
+
+ ); +}; + +type V3CardListProps = { + cards; + hasImages: boolean; +}; + +// Carousel mode: centred slideshow with a faded edge mask and dot navigation. +const V3CardList = ({ cards, hasImages }: V3CardListProps) => { + return ( +
+ + + {cards?.map((cardData, index) => { + return ( + + + + ); + })} + +
+ {cards?.map((_, index) => { + return ( + + ); + })} +
+
+
+ ); +}; + +const V3CarouselDot = ({ index }) => { + const { selectedIndex } = useCarousel(); + return ( + + ); +}; + +export function V3CardCarousel({ data }) { + const [hasImages, setHasImages] = useState(false); + const { tabsData, activeCategory, categoryGroup } = useTabCarousel({ + categoryGroup: data.categoryGroup, + }); + const [cardSet, setCardSet] = useState(data.cards); + + useEffect(() => { + if (activeCategory && data.cards) { + setCardSet( + data.cards.filter((card) => + activeCategory.cardGuidList.cardGuidList.includes(card.guid) + ) + ); + } + }, [activeCategory, data.cards]); + + useEffect(() => { + setHasImages(data.cards?.some((card) => card.image)); + setCardSet(data.cards); + }, [data.cards]); + + return ( + + +
+ {data.categoryGroup && ( + + )} +
+ {data.brow && ( + + {data.brow} + + )} + {data.isH1 ? ( +

+ +

+ ) : ( +

+ +

+ )} + {data.body && ( +

+ {data.body} +

+ )} +
+ {data.buttons?.length > 0 && ( + + )} + {data.isStacked && data.cards && ( +
= 4 && "sm:grid-cols-2 lg:grid-cols-4" + )} + > + {cardSet?.map((cardData, index) => { + return ( + + ); + })} +
+ )} +
+ {data.cards && !data.isStacked && ( + + + + )} +
+
+ ); +} diff --git a/components/blocks/v3/cta/cta.schema.tsx b/components/blocks/v3/cta/cta.schema.tsx new file mode 100644 index 0000000000..d30e3a18b4 --- /dev/null +++ b/components/blocks/v3/cta/cta.schema.tsx @@ -0,0 +1,48 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { buttonSchema } from "../../../button/templateButton.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3CtaSchema: Template = { + name: "v3Cta", + label: " CTA", + ui: { + defaultItem: { + background: { + backgroundColour: 7, + redGlow: true, + gridOverlay: true, + }, + heading: "Let's build something **you're proud of.**", + description: + "Get in touch and we'll set up an initial meeting to show you where we'd start.", + buttons: [{ buttonText: "Book your initial meeting", colour: 0 }], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Supporting text shown beneath the heading.", + toolbarOverride: ["bold", "italic", "link"], + }, + { + type: "object", + label: "Buttons", + name: "buttons", + list: true, + description: "A row of buttons. Max 2.", + ui: { + defaultItem: { buttonText: "Book your initial meeting" }, + max: 2, + itemProps: (item) => ({ label: item?.buttonText ?? "Button" }), + }, + //@ts-expect-error – fields are not being recognized + fields: buttonSchema, + }, + ], +}; diff --git a/components/blocks/v3/cta/cta.tsx b/components/blocks/v3/cta/cta.tsx new file mode 100644 index 0000000000..d06f503d83 --- /dev/null +++ b/components/blocks/v3/cta/cta.tsx @@ -0,0 +1,46 @@ +import AlternatingText from "@/components/alternating-text"; +import ButtonRow from "@/components/blocksSubtemplates/buttonRow"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +export function V3Cta({ data }) { + return ( + + + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} + +
+
+ ); +} diff --git a/components/blocks/v3/faq/faq.schema.tsx b/components/blocks/v3/faq/faq.schema.tsx new file mode 100644 index 0000000000..4bf3dccca1 --- /dev/null +++ b/components/blocks/v3/faq/faq.schema.tsx @@ -0,0 +1,68 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +// Rich-text nested inside a list isn't coerced from a string, so its default +// must be a rich-text AST object (matching accordionSchema's accordionItems). +const answerDefault = (text: string) => ({ + type: "root", + children: [{ type: "p", children: [{ type: "text", text }] }], +}); + +export const V3FaqSchema: Template = { + name: "v3Faq", + label: " FAQ", + ui: { + defaultItem: { + heading: "Frequently asked questions", + faqs: [ + { + question: "How much does it cost?", + answer: answerDefault( + "Every engagement is scoped to your goals, so there's no single price. The Health Check and first session are free, and you'll get a clear, costed plan before you commit to anything." + ), + }, + { + question: "Do you work with our existing team?", + answer: answerDefault("Yes — we embed alongside your team."), + }, + { + question: "Can you rescue a project that's already in trouble?", + answer: answerDefault("Absolutely. We start with a Health Check."), + }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + alternatingHeadingSchema, + { + type: "object", + label: "Questions", + name: "faqs", + list: true, + description: "The list of question / answer pairs.", + ui: { + itemProps: (item) => ({ label: item?.question ?? "Question" }), + defaultItem: { + question: "New question?", + answer: answerDefault("Answer goes here."), + }, + }, + fields: [ + { + type: "string", + label: "Question", + name: "question", + }, + { + type: "rich-text", + label: "Answer", + name: "answer", + toolbarOverride: ["bold", "italic", "link"], + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/faq/faq.tsx b/components/blocks/v3/faq/faq.tsx new file mode 100644 index 0000000000..9cd81e3719 --- /dev/null +++ b/components/blocks/v3/faq/faq.tsx @@ -0,0 +1,96 @@ +"use client"; + +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { FiChevronDown } from "react-icons/fi"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +export function V3Faq({ data }) { + const faqs = data?.faqs ?? []; + // First item open by default, matching the design. + const [openIndex, setOpenIndex] = useState(0); + + return ( + + + {data?.heading && ( +

+ +

+ )} + +
+ {faqs.map((faq, index) => { + const isOpen = openIndex === index; + return ( +
+ + +
+
+
+ {faq?.answer && ( + ( +

+ ), + }} + /> + )} +

+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/components/blocks/v3/featureSteps/featureSteps.schema.tsx b/components/blocks/v3/featureSteps/featureSteps.schema.tsx new file mode 100644 index 0000000000..8aa85d41c1 --- /dev/null +++ b/components/blocks/v3/featureSteps/featureSteps.schema.tsx @@ -0,0 +1,69 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3FeatureStepsSchema: Template = { + name: "v3FeatureSteps", + label: " Feature Steps", + ui: { + defaultItem: { + brow: "How it works", + heading: "A **simple** process", + description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + steps: [ + { brow: "01", heading: "Discover" }, + { brow: "02", heading: "Build" }, + { brow: "03", heading: "Deliver" }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Brow", + name: "brow", + description: "Small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Intro body text shown beneath the title (full width).", + toolbarOverride: ["bold", "italic", "link"], + }, + { + type: "object", + label: "Steps", + name: "steps", + list: true, + description: + "Numbered steps shown below the intro. Numbers auto-generate (01, 02, 03…).", + ui: { + itemProps: (item) => ({ label: item?.heading ?? "Step" }), + defaultItem: { brow: "01", heading: "Step" }, + }, + fields: [ + { + type: "string", + label: "Brow", + name: "brow", + description: "Small label above the step title (e.g. 01).", + }, + { + type: "string", + label: "Title", + name: "heading", + }, + { + type: "rich-text", + label: "Description", + name: "description", + toolbarOverride: ["bold", "italic", "link"], + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/featureSteps/featureSteps.tsx b/components/blocks/v3/featureSteps/featureSteps.tsx new file mode 100644 index 0000000000..325da5d363 --- /dev/null +++ b/components/blocks/v3/featureSteps/featureSteps.tsx @@ -0,0 +1,104 @@ +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +export function V3FeatureSteps({ data }) { + return ( + + + {/* Full-width intro: brow, title, description */} +
+ {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ + {/* Numbered steps: 01 / 02 / 03 */} + {data?.steps?.length > 0 && ( +
+ {data.steps.map((step, index) => ( +
+ {step?.brow && ( + + {step.brow} + + )} + {step?.heading && ( +

+ +

+ )} + {step?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/blocks/v3/hero/hero.schema.tsx b/components/blocks/v3/hero/hero.schema.tsx new file mode 100644 index 0000000000..bec963b5bc --- /dev/null +++ b/components/blocks/v3/hero/hero.schema.tsx @@ -0,0 +1,88 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { buttonSchema } from "../../../button/templateButton.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; +import { optimizedImageSchema } from "../../../../tina/collections/shared-fields"; + +export const V3HeroSchema: Template = { + name: "v3Hero", + label: " Hero", + ui: { + defaultItem: { + background: { + backgroundColour: 4, + backgroundImage: "", + bleed: false, + }, + brow: "Lorem Ipsum", + heading: "This is a **bold** headline", + description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + buttons: [{ buttonText: "Get Started", colour: 0 }], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Brow", + name: "brow", + description: "Small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Main body text shown beneath the title.", + toolbarOverride: ["bold", "italic", "link"], + }, + { + type: "object", + label: "Buttons", + name: "buttons", + list: true, + description: "A row of buttons. Max 2.", + ui: { + defaultItem: { buttonText: "Lorem Ipsum" }, + max: 2, + itemProps: (item) => ({ label: item?.buttonText ?? "Button" }), + }, + //@ts-expect-error – fields are not being recognized + fields: buttonSchema, + }, + { + type: "string", + label: "Media Type", + name: "mediaType", + description: + "What to show on the right-hand side. 'Image' uses the Image field below; the other options render a built-in animated composition.", + options: [ + { value: "image", label: "Image" }, + { + value: "reactConsultingSvg", + label: "React Consulting Atom (animated)", + }, + ], + }, + { + type: "object", + label: "Image", + name: "image", + description: + "The image displayed on the right-hand side (only used when Media Type is 'Image').", + fields: [ + { + type: "string", + label: "Alt Text", + name: "altText", + description: "Alt text for the hero image.", + }, + // @ts-expect-error – optimizedImageSchema's field types aren't recognised + ...optimizedImageSchema( + "Upload the hero image. A landscape or square aspect ratio works best." + ), + ], + }, + ], +}; diff --git a/components/blocks/v3/hero/hero.tsx b/components/blocks/v3/hero/hero.tsx new file mode 100644 index 0000000000..22420b7b53 --- /dev/null +++ b/components/blocks/v3/hero/hero.tsx @@ -0,0 +1,101 @@ +"use client"; +import AlternatingText from "@/components/alternating-text"; +import ButtonRow from "@/components/blocksSubtemplates/buttonRow"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; +import { heroMediaRegistry } from "./media/registry"; + +export const V3Hero = ({ data, priority = false }) => { + const image = data?.image; + const mediaType = data?.mediaType ?? "image"; + const MediaComponent = + mediaType !== "image" ? heroMediaRegistry[mediaType] : undefined; + const hasImage = + mediaType === "image" && + image?.imageSource && + image?.imageWidth && + image?.imageHeight; + + return ( + + + {/* Left-hand side: brow, title, description, buttons */} +
+ {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} + +
+ + {/* Right-hand side: registered animated media takes precedence, + otherwise fall back to the Tina image. */} + {MediaComponent ? ( +
+ +
+ ) : ( + hasImage && ( +
+ {image.altText +
+ ) + )} +
+
+ ); +}; diff --git a/components/blocks/v3/hero/media/ReactConsultingHeroMedia.tsx b/components/blocks/v3/hero/media/ReactConsultingHeroMedia.tsx new file mode 100644 index 0000000000..aec951d761 --- /dev/null +++ b/components/blocks/v3/hero/media/ReactConsultingHeroMedia.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useReducedMotion } from "framer-motion"; + +/** + * Atom composition for the React Consulting v3 hero RHS. + * + * Owns the full relationship between the three source SVGs: + * - Hero-Atom.svg -> the three orbital rings (static base layer) + * - Hero-Atom-Nucleus -> the centre dot (subtle pulse) + * - Hero-Atom-Electron -> dots that travel along each ring (orbit) + * + * Everything is inlined into a single SVG so the electrons can follow the + * real ring paths in SVG coordinate space (via ), + * which a transform-based orbit can't do for tilted ellipses. + */ + +// Ring paths lifted straight from Hero-Atom.svg (viewBox 0 0 338 300). +const RINGS = [ + "M168.773 211.474C261.155 211.474 336.045 183.797 336.045 149.655C336.045 115.514 261.155 87.8371 168.773 87.8371C76.3906 87.8371 1.5 115.514 1.5 149.655C1.5 183.797 76.3906 211.474 168.773 211.474Z", + "M115.237 180.564C161.428 260.569 222.842 311.588 252.409 294.517C281.977 277.446 268.5 198.751 222.309 118.745C176.118 38.7402 114.704 -12.2785 85.1367 4.79214C55.5696 21.8628 69.0459 100.558 115.237 180.564Z", + "M115.237 118.745C69.0456 198.75 55.5693 277.446 85.1365 294.516C114.704 311.587 176.118 260.568 222.309 180.563C268.5 100.558 281.976 21.862 252.409 4.79133C222.842 -12.2793 161.428 38.7394 115.237 118.745Z", +]; + +// Atom centre within the viewBox (where the nucleus sits). +const CENTRE = { x: 168.773, y: 149.655 }; + +// Start point of each ring path — where electrons rest under reduced motion. +const RING_STARTS = [ + { x: 168.773, y: 211.474 }, + { x: 115.237, y: 180.564 }, + { x: 115.237, y: 118.745 }, +]; + +const RING_COLOUR = "#CC4141"; +const ELECTRON_COLOUR = "#DA7373"; + +export default function ReactConsultingHeroMedia() { + const reduceMotion = useReducedMotion(); + + return ( + + {/* Static orbital rings */} + {RINGS.map((d, i) => ( + + ))} + + {/* Nucleus */} + + {!reduceMotion && ( + + )} + + + {/* Electrons — one per ring, staggered so they don't bunch up */} + {RINGS.map((_, i) => + reduceMotion ? ( + // Reduced motion: park each electron at its ring's start point. + + ) : ( + + + + + + ) + )} + + ); +} diff --git a/components/blocks/v3/hero/media/registry.tsx b/components/blocks/v3/hero/media/registry.tsx new file mode 100644 index 0000000000..25b4aa3e39 --- /dev/null +++ b/components/blocks/v3/hero/media/registry.tsx @@ -0,0 +1,18 @@ +import dynamic from "next/dynamic"; +import type { ComponentType } from "react"; + +/** + * Registry of pluggable RHS media compositions for the v3 hero. + * + * Tina can't store JSX in JSON, so the `mediaType` schema field stores a + * string key and the hero resolves it to a component here at render time. + * Each entry owns its own internal layout/animation — the hero is agnostic. + * + * To add a new composition: drop a component in this folder and register its + * key below, then add the same key to the `mediaType` options in the schema. + */ +export const heroMediaRegistry: Record = { + reactConsultingSvg: dynamic(() => import("./ReactConsultingHeroMedia")), +}; + +export type HeroMediaKey = keyof typeof heroMediaRegistry; diff --git a/components/blocks/v3/leadCapture/leadCapture.template.tsx b/components/blocks/v3/leadCapture/leadCapture.template.tsx new file mode 100644 index 0000000000..5be593f490 --- /dev/null +++ b/components/blocks/v3/leadCapture/leadCapture.template.tsx @@ -0,0 +1,35 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +// The form fields, screens and JotForm mapping are fixed — every use of this +// block submits to the same JotForm. The CMS only controls the intro copy and +// background; see leadCapture.tsx for the JotForm id / question-id mapping. +export const V3LeadCaptureSchema: Template = { + name: "v3LeadCapture", + label: " Lead Capture", + ui: { + defaultItem: { + brow: "Get started", + heading: "Let's find the **right** starting point", + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Brow", + name: "brow", + description: "Small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Optional intro text shown above the form.", + toolbarOverride: ["bold", "italic", "link"], + }, + ], +}; diff --git a/components/blocks/v3/leadCapture/leadCapture.tsx b/components/blocks/v3/leadCapture/leadCapture.tsx new file mode 100644 index 0000000000..e0f1677081 --- /dev/null +++ b/components/blocks/v3/leadCapture/leadCapture.tsx @@ -0,0 +1,430 @@ +"use client"; + +import AlternatingText from "@/components/alternating-text"; +import { SSWAdaptiveField } from "@/components/ssw/field"; +import { Field, FieldLabel } from "@/components/ui/field"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { TiArrowLeft } from "react-icons/ti"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +// Every use of this block submits to the same JotForm, so the form id and the +// question-id (qid) mapping are fixed here rather than configured in the CMS. +const JOTFORM_ID = "233468468973070"; +const QID = { + name: "16", + email: "4", + company: "7", + phone: "17", + location: "6", + hearAboutUs: "8", + message: "9", + landingPageUrl: "20", +}; + +const HEAR_ABOUT_OPTIONS = [ + "Conference", + "Google", + "Government Suppliers List", + "Repeat Business", + "Events", + "Referral", + "Signage", + "SSW TV", + "Other", +]; + +// Location: countries, with Australia revealing state sub-options. +const LOCATIONS: { label: string; states: string[] }[] = [ + { + label: "Australia", + states: ["ACT", "NSW", "NT", "QLD", "SA", "TAS", "VIC"], + }, + { label: "China", states: [] }, + { label: "Europe", states: [] }, + { label: "USA", states: [] }, +]; + +// The UI keeps the short state codes; JotForm receives the full state name. +const AU_STATE_NAMES: Record = { + ACT: "Australian Capital Territory", + NSW: "New South Wales", + NT: "Northern Territory", + QLD: "Queensland", + SA: "South Australia", + TAS: "Tasmania", + VIC: "Victoria", + WA: "Western Australia", +}; + +const TOTAL_SCREENS = 3; + +const primaryButtonClass = + "rounded-lg bg-sswRed px-6 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"; + +const backButtonClass = + "inline-flex items-center gap-2 text-xs text-gray-400 transition-colors hover:text-white"; + +type SubmitState = "idle" | "submitting" | "success" | "error"; + +export function V3LeadCapture({ data }) { + const [current, setCurrent] = useState(0); + const [status, setStatus] = useState("idle"); + + // Screen 1 — your details + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [company, setCompany] = useState(""); + const [phone, setPhone] = useState(""); + // Screen 2 — location + how you heard + const [country, setCountry] = useState(""); + const [region, setRegion] = useState(""); + const [hearAboutUs, setHearAboutUs] = useState(""); + // Screen 3 — how can we help + const [message, setMessage] = useState(""); + + const progress = Math.round(((current + 1) / TOTAL_SCREENS) * 100); + const goBack = () => setCurrent((c) => Math.max(c - 1, 0)); + const goNext = () => setCurrent((c) => Math.min(c + 1, TOTAL_SCREENS - 1)); + + const selectedCountry = LOCATIONS.find((l) => l.label === country); + const needsRegion = (selectedCountry?.states.length ?? 0) > 0; + // JotForm's location field takes country and state as two separate options + // (e.g. "Australia" + "New South Wales") rather than one combined string. + const regionName = + needsRegion && region ? (AU_STATE_NAMES[region] ?? region) : ""; + const locationValue: string | string[] = regionName + ? [country, regionName] + : country; + + const screen1Valid = + name.trim().length > 0 && + /.+@.+\..+/.test(email.trim()) && + company.trim().length > 0 && + phone.trim().length > 0; + + const screen2Valid = Boolean(country) && (!needsRegion || Boolean(region)); + + const screen3Valid = message.trim().length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (status === "submitting") return; + setStatus("submitting"); + + const fields: Record = { + [QID.name]: name.trim(), + [QID.email]: email.trim(), + [QID.company]: company.trim(), + [QID.phone]: phone.trim(), + [QID.location]: locationValue, + [QID.message]: message.trim(), + }; + if (hearAboutUs) fields[QID.hearAboutUs] = hearAboutUs; + if (typeof window !== "undefined") { + fields[QID.landingPageUrl] = window.location.href; + } + + try { + const res = await fetch("/api/lead-capture", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jotFormId: JOTFORM_ID, fields }), + }); + setStatus(res.ok ? "success" : "error"); + } catch { + setStatus("error"); + } + }; + + return ( + + + {/* Intro */} + {(data?.brow || data?.heading || data?.description) && ( +
+ {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ )} + +
+ {/* Progress */} +
+
+
+
+ + {current + 1}/{TOTAL_SCREENS} + +
+ + {status === "success" ? ( +
+

+ Thanks — we've got it. +

+

+ A senior React engineer will be in touch with you shortly. +

+
+ ) : current === 0 ? ( + /* Screen 1 — your details */ +
+

+ Get in contact - let us know a bit about you. +

+
+ + Name * + + } + type="text" + required + value={name} + onChange={(e) => setName(e.target.value)} + /> + + Email * + + } + type="email" + required + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + + Company * + + } + type="text" + required + value={company} + onChange={(e) => setCompany(e.target.value)} + /> + + Phone * + + } + type="tel" + required + value={phone} + onChange={(e) => setPhone(e.target.value)} + /> +
+
+ +
+
+ ) : current === 1 ? ( + /* Screen 2 — location + how you heard */ +
+

+ Where are you located? * +

+
+
+ {LOCATIONS.map((loc) => { + const isSelected = country === loc.label; + return ( + + ); + })} +
+ + {needsRegion && ( +
+ {selectedCountry?.states.map((st) => { + const isSelected = region === st; + return ( + + ); + })} +
+ )} + + + + How did you hear about us?{" "} + (optional) + + + +
+ +
+ + +
+
+ ) : ( + /* Screen 3 — how can we help + submit */ +
+ + How can we help you? * + + } + required + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + + {status === "error" && ( +

+ Something went wrong — please try again. +

+ )} + +
+ + +
+ + )} +
+ + + ); +} diff --git a/components/blocks/v3/logoCarousel/logoCarousel.schema.tsx b/components/blocks/v3/logoCarousel/logoCarousel.schema.tsx new file mode 100644 index 0000000000..fd7da089b7 --- /dev/null +++ b/components/blocks/v3/logoCarousel/logoCarousel.schema.tsx @@ -0,0 +1,55 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3LogoCarouselSchema: Template = { + name: "v3LogoCarousel", + label: " Logo Carousel", + ui: { + defaultItem: { + heading: "Trusted by **industry leaders**", + logos: [{ logo: "", altText: "Logo" }], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + alternatingHeadingSchema, + { + type: "boolean", + name: "paused", + label: "Paused", + description: "Remember to enable this before deploying to production.", + }, + { + type: "boolean", + label: "Mask Images and Whiten", + name: "isWhiteImages", + description: "Completely saturates images so they appear white.", + }, + { + type: "object", + label: "Logos", + name: "logos", + description: "Individual logos in the carousel.", + list: true, + ui: { + itemProps: (item) => ({ label: item?.altText ?? "Logo" }), + }, + fields: [ + { + type: "image", + label: "Logo Source", + name: "logo", + description: "The image to display in the carousel.", + }, + { + type: "string", + label: "Alt Text", + name: "altText", + description: "Alt text for the logo image. Defaults to 'Logo'.", + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/logoCarousel/logoCarousel.tsx b/components/blocks/v3/logoCarousel/logoCarousel.tsx new file mode 100644 index 0000000000..dd101686c2 --- /dev/null +++ b/components/blocks/v3/logoCarousel/logoCarousel.tsx @@ -0,0 +1,52 @@ +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Marquee } from "@/components/ui/marquee"; +import { Container } from "@/components/util/container"; +import Image from "next/image"; +import { tinaField } from "tinacms/dist/react"; + +export function V3LogoCarousel({ data }) { + return ( + +
+ +
+ {data?.heading && ( +

+ +

+ )} +
+ +
+ {data?.logos?.map((logo, index) => ( +
+ {logo?.altText +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/components/blocks/v3/process/process.schema.tsx b/components/blocks/v3/process/process.schema.tsx new file mode 100644 index 0000000000..9787a2a459 --- /dev/null +++ b/components/blocks/v3/process/process.schema.tsx @@ -0,0 +1,62 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3ProcessSchema: Template = { + name: "v3Process", + label: " Process", + ui: { + defaultItem: { + brow: "Lorem ipsum", + heading: "Lorem **ipsum** dolor sit amet", + steps: [ + { heading: "Lorem ipsum" }, + { heading: "Lorem ipsum" }, + { heading: "Lorem ipsum" }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Brow", + name: "brow", + description: "Small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Optional intro body text shown beneath the title.", + toolbarOverride: ["bold", "italic", "link"], + }, + { + type: "object", + label: "Steps", + name: "steps", + list: true, + description: + "Numbered process steps. The circled number auto-generates from order (01, 02, 03…).", + ui: { + itemProps: (item) => ({ label: item?.heading ?? "Step" }), + defaultItem: { heading: "Lorem ipsum" }, + }, + fields: [ + { + type: "string", + label: "Title", + name: "heading", + }, + { + type: "rich-text", + label: "Description", + name: "description", + toolbarOverride: ["bold", "italic", "link"], + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/process/process.tsx b/components/blocks/v3/process/process.tsx new file mode 100644 index 0000000000..b9d1534c33 --- /dev/null +++ b/components/blocks/v3/process/process.tsx @@ -0,0 +1,98 @@ +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +export function V3Process({ data }) { + return ( + + + {/* Full-width intro: brow, title, description */} +
+ {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ + {/* Numbered steps: circled number + connector line */} + {data?.steps?.length > 0 && ( +
+ {data.steps.map((step, index) => ( +
+
+ + + {String(index + 1).padStart(2)} + + +
+ {step?.heading && ( +

+ +

+ )} + {step?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/blocks/v3/stackCards/stackCards.schema.tsx b/components/blocks/v3/stackCards/stackCards.schema.tsx new file mode 100644 index 0000000000..144f046038 --- /dev/null +++ b/components/blocks/v3/stackCards/stackCards.schema.tsx @@ -0,0 +1,97 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3StackCardsSchema: Template = { + name: "v3StackCards", + label: " Stack Cards", + ui: { + defaultItem: { + brow: "Full Stack Engineers", + heading: "One team for the whole build", + subtitle: "React is the front door. We build the whole house.", + cards: [ + { + title: "Next.js", + description: + "Fast, search-friendly web apps, with React under the hood.", + link: "/", + }, + { + title: "React Native", + description: "Mobile apps built with your favorite React stack.", + link: "/", + }, + { + title: ".NET", + description: "Enterprise backends that stand the test of time.", + link: "/", + }, + { + title: "Azure", + description: + "Cloud infrastructure that keeps your app fast as it scales.", + link: "/", + }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Brow", + name: "brow", + description: "Small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "string", + label: "Subtitle", + name: "subtitle", + description: "Short subtitle shown beneath the title.", + ui: { component: "textarea" }, + }, + { + type: "object", + label: "Cards", + name: "cards", + list: true, + description: "Up to 4 stack cards shown in a row.", + ui: { + max: 4, + itemProps: (item) => ({ label: item?.title ?? "Card" }), + defaultItem: { + title: "Next.js", + description: + "Fast, search-friendly web apps, with React under the hood.", + link: "/", + }, + }, + fields: [ + { + type: "string", + label: "Title", + name: "title", + }, + { + type: "string", + label: "Description", + name: "description", + ui: { component: "textarea" }, + }, + { + type: "string", + label: "Link", + name: "link", + }, + { + type: "boolean", + label: "Open in New Tab", + name: "newTab", + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/stackCards/stackCards.tsx b/components/blocks/v3/stackCards/stackCards.tsx new file mode 100644 index 0000000000..286f58851a --- /dev/null +++ b/components/blocks/v3/stackCards/stackCards.tsx @@ -0,0 +1,89 @@ +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import Link from "next/link"; +import { BsArrowUpRight } from "react-icons/bs"; +import { tinaField } from "tinacms/dist/react"; + +export function V3StackCards({ data }) { + const cards = data?.cards ?? []; + + return ( + + + {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.subtitle && ( +

+ {data.subtitle} +

+ )} + + {cards.length > 0 && ( +
+ {cards.map((card, index) => { + const inner = ( +
+
+

+ {card?.title} +

+ + + +
+ {card?.description && ( +

+ {card.description} +

+ )} +
+ ); + + return card?.link ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/components/blocks/v3/statistics/statistics.schema.tsx b/components/blocks/v3/statistics/statistics.schema.tsx new file mode 100644 index 0000000000..04b04cce2b --- /dev/null +++ b/components/blocks/v3/statistics/statistics.schema.tsx @@ -0,0 +1,77 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3StatisticsTemplate: Template = { + name: "v3Statistics", + label: " Statistics", + ui: { + defaultItem: { + heading: "Why teams choose **SSW**", + statistics: [ + { + figure: 1000, + figureSuffix: "+", + heading: "Solutions delivered", + description: "Enterprise-grade software", + }, + { + figure: 1000, + figureSuffix: "+", + heading: "Happy clients", + description: "Across all industries", + }, + { + figure: 30, + figureSuffix: "+", + heading: "Years experience", + description: "Trusted since 1999", + }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + alternatingHeadingSchema, + { + type: "object", + label: "Statistics", + name: "statistics", + list: true, + description: "Blocks for statistics - maximum of 3", + ui: { + max: 3, + itemProps: (item) => ({ label: item?.heading ?? "Statistic" }), + defaultItem: { + figure: 100, + figureSuffix: "+", + heading: "Statistic", + description: "Short supporting line", + }, + }, + fields: [ + { + type: "number", + label: "Figure", + name: "figure", + }, + { + type: "string", + label: "Figure Suffix", + name: "figureSuffix", + }, + { + type: "string", + label: "Title", + name: "heading", + }, + { + type: "string", + label: "Description", + name: "description", + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/statistics/statistics.tsx b/components/blocks/v3/statistics/statistics.tsx new file mode 100644 index 0000000000..d88b9ba4dd --- /dev/null +++ b/components/blocks/v3/statistics/statistics.tsx @@ -0,0 +1,89 @@ +import AlternatingText from "@/components/alternating-text"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { cn } from "@/lib/utils"; +import { tinaField } from "tinacms/dist/react"; + +// Border logic per cell (0 = intro, 1-3 = stats), assuming a 4-cell layout: +// - <768px : vertical stack, horizontal line between each row +// - 768-1024: 2x2 square, internal cross (left col gets right edge, top row +// gets bottom edge); outer top/bottom comes from the wrapper +// - >=1024px: single row, vertical line between each column +const cellBorder = (i: number) => + cn( + "border-[#212121]", + // stacked + i > 0 && "border-t-[0.75px]", + // square (2x2) + "md:border-t-0", + (i === 0 || i === 2) && "md:border-r-[0.75px]", + (i === 0 || i === 1) && "md:border-b-[0.75px]", + // single row + "lg:border-b-0 lg:border-r-0", + i > 0 && "lg:border-l-[0.75px]" + ); + +export default function V3Statistics({ data }) { + const stats = data?.statistics ?? []; + + return ( + + +
+ {/* Intro: heading on the left */} +
+ {data?.heading && ( +

+ +

+ )} +
+ + {/* Statistic columns */} + {stats.map((stat, index) => ( +
+
+ + {stat?.figure} + + {stat?.figureSuffix && ( + + {stat.figureSuffix} + + )} +
+ {stat?.heading && ( +

+ {stat.heading} +

+ )} + {stat?.description && ( +

+ {stat.description} +

+ )} +
+ ))} +
+
+
+ ); +} diff --git a/components/blocks/v3/testimonials/testimonials.schema.tsx b/components/blocks/v3/testimonials/testimonials.schema.tsx new file mode 100644 index 0000000000..c6fe6ce423 --- /dev/null +++ b/components/blocks/v3/testimonials/testimonials.schema.tsx @@ -0,0 +1,86 @@ +import type { Template } from "tinacms"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; + +export const V3TestimonialsSchema: Template = { + name: "v3Testimonials", + label: " Testimonials", + ui: { + defaultItem: { + testimonials: [ + { + quote: + "SSW transformed our legacy systems. Their team delivered a **cutting-edge React app** that streamlined operations and enhanced citizen services.", + authorName: "Alex Johnson", + authorTitle: "CTO, GovTech Agency", + }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "object", + label: "Testimonials", + name: "testimonials", + list: true, + description: "Each item is a slide in the testimonial carousel.", + ui: { + itemProps: (item) => ({ label: item?.authorName ?? "Testimonial" }), + defaultItem: { + quote: "**Lorem ipsum** dolor sit amet, consectetur adipiscing elit.", + authorName: "Author Name", + authorTitle: "Role, Company", + }, + }, + fields: [ + { + type: "string", + label: "Quote", + name: "quote", + description: "Use **double asterisks** to highlight text in red.", + ui: { component: "textarea" }, + }, + { + type: "string", + label: "Case Study URL", + name: "caseStudyUrl", + description: + "If set, a 'SEE CASE STUDY' link is shown below the quote.", + }, + { + type: "string", + label: "Author Name", + name: "authorName", + }, + { + type: "string", + label: "Author Title", + name: "authorTitle", + description: "e.g. CTO, GovTech Agency", + }, + { + type: "image", + label: "Author Image", + name: "authorImage", + description: "Headshot of the author.", + }, + { + type: "string", + label: "Author Image Alt Text", + name: "authorImageAlt", + }, + { + type: "image", + label: "Company Logo", + name: "companyLogo", + }, + { + type: "string", + label: "Company Logo Alt Text", + name: "companyLogoAlt", + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/testimonials/testimonials.tsx b/components/blocks/v3/testimonials/testimonials.tsx new file mode 100644 index 0000000000..96825593ce --- /dev/null +++ b/components/blocks/v3/testimonials/testimonials.tsx @@ -0,0 +1,243 @@ +"use client"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { motion } from "framer-motion"; +import Image from "next/image"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { BiLeftArrowAlt, BiRightArrowAlt } from "react-icons/bi"; +import { TiArrowRight } from "react-icons/ti"; +import { tinaField } from "tinacms/dist/react"; + +type RevealToken = + | { space: true; word?: undefined; red?: undefined } + | { space?: false; word: string; red: boolean }; + +// Clip-style text reveal: words rise into view from behind a mask, staggered +// line-by-line. Lines are detected by measuring each word's rendered vertical +// position, so the stagger follows the real wrap points. Remount (via a `key` +// on the parent) re-fires the reveal on each slide switch. Preserves **red** +// emphasis (same convention as AlternatingText). +function ClipTextReveal({ text }: { text: string }) { + const wordRefs = useRef<(HTMLSpanElement | null)[]>([]); + const [lineIndices, setLineIndices] = useState(null); + + const tokens = useMemo(() => { + if (!text) return []; + const out: RevealToken[] = []; + text + .split(/(\*\*.*?\*\*)/g) + .filter(Boolean) + .forEach((seg) => { + const match = seg.match(/^\*\*(.*)\*\*$/); + const red = Boolean(match); + (match ? match[1] : seg).split(/(\s+)/).forEach((tok) => { + if (tok === "") return; + if (/^\s+$/.test(tok)) out.push({ space: true }); + else out.push({ word: tok, red }); + }); + }); + return out; + }, [text]); + + const wordCount = tokens.filter((t) => !t.space).length; + + // After layout, group words sharing the same offsetTop into a line index. + useEffect(() => { + let line = -1; + let lastTop: number | null = null; + const indices = wordRefs.current + .slice(0, wordCount) + .map((el) => (el ? el.offsetTop : 0)) + .map((top) => { + if (lastTop === null || top !== lastTop) { + line += 1; + lastTop = top; + } + return line; + }); + setLineIndices(indices); + }, [wordCount]); + + if (!text) return null; + + let wordIndex = -1; + + return ( + + {tokens.map((tok, ti) => { + if (tok.space) return ; + const i = ++wordIndex; + return ( + { + wordRefs.current[i] = el; + }} + aria-hidden + className="-mb-descender inline-block overflow-hidden pb-descender align-bottom" + > + + {tok.word} + + + ); + })} + + ); +} + +export function V3Testimonials({ data }) { + const testimonials = data?.testimonials ?? []; + const [active, setActive] = useState(0); + + if (testimonials.length === 0) return null; + + // Guard against the active index pointing past a shortened list while editing. + const current = testimonials[Math.min(active, testimonials.length - 1)]; + + const goPrev = () => + setActive((i) => (i - 1 + testimonials.length) % testimonials.length); + const goNext = () => setActive((i) => (i + 1) % testimonials.length); + + return ( + + +
+ {/* Quote + author */} +
+ {current?.quote && ( +
+ +
+ )} + + {current?.caseStudyUrl && ( + + See Case Study + + + )} + +
+ +
+ {current?.authorName && ( + + {current.authorName} + + )} + {current?.authorTitle && ( + + {current.authorTitle} + + )} +
+ + {current?.companyLogo && ( + <> + + {current?.companyLogoAlt + + )} +
+ + {/* Carousel controls */} + {testimonials.length > 1 && ( +
+ + +
+ )} +
+
+ + {/* Author image */} + {current?.authorImage && ( + + { + + )} +
+
+
+ ); +} diff --git a/components/blocks/v3/videoHighlights/videoHighlights.schema.tsx b/components/blocks/v3/videoHighlights/videoHighlights.schema.tsx new file mode 100644 index 0000000000..844f34c8ff --- /dev/null +++ b/components/blocks/v3/videoHighlights/videoHighlights.schema.tsx @@ -0,0 +1,99 @@ +import type { Template } from "tinacms"; +import alternatingHeadingSchema from "../../../blocksSubtemplates/alternatingHeading.schema"; +import { backgroundSchema } from "../../../layout/v2ComponentWrapper.schema"; +import { IconPickerInput } from "../../../blocksSubtemplates/tinaFormElements/iconSelector"; + +export const V3VideoHighlightsSchema: Template = { + name: "v3VideoHighlights", + label: " Video Highlights", + ui: { + defaultItem: { + heading: "Why choose **SSW**?", + highlights: [ + { icon: "BiCube", title: "Deep expertise" }, + { icon: "BiTargetLock", title: "Results over rhetoric" }, + ], + }, + }, + fields: [ + //@ts-expect-error – custom component typing won't be pinned down + backgroundSchema, + { + type: "string", + label: "Video URL", + name: "videoUrl", + description: "YouTube or Vimeo URL shown on the left with a play button.", + }, + { + type: "image", + label: "Thumbnail Override", + name: "thumbnail", + description: + "Optional. Overrides the video's poster image; falls back to the video's own thumbnail when empty.", + }, + { + type: "boolean", + label: "Greyscale Thumbnail", + name: "greyscaleThumbnail", + description: + "Show the thumbnail in greyscale. The video plays in full colour.", + }, + { + type: "string", + label: "Figure Caption", + name: "figure", + description: "Optional caption shown beneath the video.", + }, + { + type: "string", + label: "Brow", + name: "brow", + description: "Optional small eyebrow text above the title.", + }, + alternatingHeadingSchema, + { + type: "rich-text", + label: "Description", + name: "description", + description: "Intro body text shown beneath the title.", + toolbarOverride: ["bold", "italic", "link"], + }, + { + type: "object", + label: "Highlights", + name: "highlights", + list: true, + description: "Icon + title + description columns shown beside the video.", + ui: { + itemProps: (item) => ({ label: item?.title ?? "Highlight" }), + defaultItem: { + icon: "BiCube", + title: "Highlight title", + }, + }, + fields: [ + // @ts-expect-error – Tina 3.8.x: custom ui.component type no longer matches Field + { + type: "string", + label: "Icon", + name: "icon", + description: "Icon shown above the title.", + ui: { + component: IconPickerInput, + }, + }, + { + type: "string", + label: "Title", + name: "title", + }, + { + type: "rich-text", + label: "Description", + name: "desc2", + toolbarOverride: ["bold", "italic", "link"], + }, + ], + }, + ], +}; diff --git a/components/blocks/v3/videoHighlights/videoHighlights.tsx b/components/blocks/v3/videoHighlights/videoHighlights.tsx new file mode 100644 index 0000000000..2cd3bcef83 --- /dev/null +++ b/components/blocks/v3/videoHighlights/videoHighlights.tsx @@ -0,0 +1,127 @@ +import AlternatingText from "@/components/alternating-text"; +import { Icon } from "@/components/blocksSubtemplates/tinaFormElements/icon"; +import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; +import { Container } from "@/components/util/container"; +import { VideoModal } from "@/components/videoModal"; +import { cn } from "@/lib/utils"; +import { tinaField } from "tinacms/dist/react"; +import { TinaMarkdown } from "tinacms/dist/rich-text"; + +export function V3VideoHighlights({ data }) { + return ( + + +
+ {/* Video */} + {data?.videoUrl && ( +
+
+ +
+ {data?.figure && ( +
+ {data.figure} +
+ )} +
+ )} + + {/* Heading + description + highlights */} +
+ {data?.brow && ( + + {data.brow} + + )} + {data?.heading && ( +

+ +

+ )} + {data?.description && ( +
+ ( +

+ ), + }} + /> +

+ )} + + {data?.highlights?.length > 0 && ( +
+ {data.highlights.map((item, index) => ( +
+ {item?.icon && ( + + )} + {item?.title && ( +

+ {item.title} +

+ )} + {item?.desc2 && ( +
+ ( +

+ ), + }} + /> +

+ )} +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/components/blocksSubtemplates/buttonRow.tsx b/components/blocksSubtemplates/buttonRow.tsx index f97fbb1cb0..4c847236a7 100644 --- a/components/blocksSubtemplates/buttonRow.tsx +++ b/components/blocksSubtemplates/buttonRow.tsx @@ -78,14 +78,34 @@ const ButtonRow = ({ className, data }) => { /> ); + const isInPageAnchor = button.buttonLink?.startsWith("#"); + return button.buttonLink && !button.showLeadCaptureForm ? ( - - {buttonElement} - + isInPageAnchor ? ( + { + e.preventDefault(); + const target = document.getElementById( + button.buttonLink.slice(1) + ); + target?.scrollIntoView({ behavior: "smooth" }); + history.replaceState(null, "", button.buttonLink); + }} + > + {buttonElement} + + ) : ( + + {buttonElement} + + ) ) : ( {buttonElement} diff --git a/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions.tsx b/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions.tsx index e5dcbe077d..82107f5de3 100644 --- a/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions.tsx +++ b/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions.tsx @@ -5,36 +5,57 @@ export const backgroundOptions: ColorPickerOptions[] = [ name: "Soft Left Gradient", classes: "bg-gradient-to-l from-gray-900 to-[#121212] text-white", reference: 0, + hex: "#111827 → #121212", }, { name: "Soft Right Gradient", classes: "bg-gradient-to-r from-gray-900 to-[#121212] text-white", reference: 1, + hex: "#111827 → #121212", }, { name: "Sheer Top Gradient", classes: "bg-gradient-to-t from-gray-900 to-black text-white", reference: 2, + hex: "#111827 → #000000", }, { name: "Sheer Bottom Gradient", classes: "bg-gradient-to-b from-gray-900 to-black text-white", reference: 3, + hex: "#111827 → #000000", }, { name: "Dark Gray", classes: "bg-gray-950 text-white", reference: 4, editorClasses: "bg-[#222222] text-white", + hex: "#030712", }, { name: "Gray", classes: "bg-gray-900 text-white", reference: 5, + hex: "#111827", }, { name: "Black", classes: "bg-black text-white", reference: 6, + hex: "#000000", + }, + { + name: "SSW Dark Gray", + classes: "bg-sswDarkGray text-white", + reference: 7, + editorClasses: "bg-[#090909] text-white", + hex: "#090909", + }, + { + name: "SSW Median Gray", + classes: "bg-[#151515] text-white", + reference: 8, + editorClasses: "bg-[#151515] text-white", + hex: "#151515", }, ]; diff --git a/components/blocksSubtemplates/tinaFormElements/colourSelector.tsx b/components/blocksSubtemplates/tinaFormElements/colourSelector.tsx index 9b230e6717..f0c48554e8 100644 --- a/components/blocksSubtemplates/tinaFormElements/colourSelector.tsx +++ b/components/blocksSubtemplates/tinaFormElements/colourSelector.tsx @@ -6,6 +6,7 @@ export interface ColorPickerOptions { classes: string; editorClasses?: string; reference: number; + hex?: string; } export const ColorPickerInput = (colours: ColorPickerOptions[]) => { @@ -29,7 +30,14 @@ export const ColorPickerInput = (colours: ColorPickerOptions[]) => { input.onChange(colour.reference); }} > - {colour.name} + + {colour.name} + {colour.hex && ( + + {colour.hex} + + )} + ); })} diff --git a/components/layout/v2ComponentWrapper.schema.tsx b/components/layout/v2ComponentWrapper.schema.tsx index 0e2b546fb0..bfe7456a1a 100644 --- a/components/layout/v2ComponentWrapper.schema.tsx +++ b/components/layout/v2ComponentWrapper.schema.tsx @@ -39,5 +39,19 @@ export const backgroundSchema = { name: "bleed", description: "If true, the background will bleed into lower blocks.", }, + { + type: "boolean", + label: "Grid Overlay", + name: "gridOverlay", + description: + "Overlays a subtle dotted grid on top of the background, underneath all content.", + }, + { + type: "boolean", + label: "Red Glow", + name: "redGlow", + description: + "Adds soft red radial glows to the background (top-left and bottom-middle).", + }, ], }; diff --git a/components/layout/v2ComponentWrapper.tsx b/components/layout/v2ComponentWrapper.tsx index bb0119c273..59e6cdfaab 100644 --- a/components/layout/v2ComponentWrapper.tsx +++ b/components/layout/v2ComponentWrapper.tsx @@ -12,6 +12,8 @@ type BackgroundData = { backgroundColour?: number; backgroundImage?: string; bleed?: boolean; + gridOverlay?: boolean; + redGlow?: boolean; }; }; @@ -86,6 +88,24 @@ const V2ComponentWrapper = ({ }} /> )} + {data.background?.redGlow && ( + <> +