Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions src/_components/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,46 @@
* Source: TDS § 3 wireframe (lines 557–574)
*/

import { enrichCtaSectionData } from "@/lib/cta-channels";
import { prisma } from "@/lib/prisma";
import type { CtaSectionData } from "@/types";

export async function SiteFooter() {
const currentYear = new Date().getFullYear();

// Fetch the canonical CTA section by slug for predictable data
const ctaSection = await prisma.section.findFirst({
where: { slug: "cta-default" },
select: { data: true },
});

const channels: any[] = (ctaSection?.data as any)?.channels || [];
const ctaData = enrichCtaSectionData(
(ctaSection?.data as Record<string, unknown>) ?? {}
) as unknown as CtaSectionData;
const channels = ctaData.channels ?? [];

return (
<footer className="w-full border-t border-border bg-surface">
<div className="container mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 px-4 py-8 sm:flex-row">
{/* Lightweight CTA */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
<a
href="/files/resume.pdf"
target="_blank"
className="text-sm text-secondary transition-colors duration-150 hover:text-accent"
>
Resume
</a>
{channels.map((channel) => (
<a
key={channel.short_label || channel.label}
href={channel.href}
target="_blank"
rel={
channel.href.includes("https://")
? "noopener noreferrer"
: undefined
}
target={channel.target}
rel={channel.rel}
className="text-sm text-secondary transition-colors duration-150 hover:text-accent"
>
{channel.short_label || channel.label}
</a>
))}
<a
href="/files/resume.pdf"
target="_blank"
className="text-sm text-secondary transition-colors duration-150 hover:text-accent"
>
Resume
</a>
</div>

{/* Legal */}
Expand Down
19 changes: 4 additions & 15 deletions src/_components/sections/CTASection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,7 @@
* Source: TDS § 3 wireframe (lines 499–537)
*/

interface CTAChannel {
label: string;
href: string;
icon: string;
micro_copy: string;
}

interface CTAData {
heading: string;
subheading?: string;
channels: CTAChannel[];
}
import type { CtaSectionData } from "@/types";

/** Inline SVG icons for the four CTA channels */
function ChannelIcon({ icon }: { icon: string }) {
Expand Down Expand Up @@ -53,7 +42,7 @@ function ChannelIcon({ icon }: { icon: string }) {
}

export function CTASection({ data }: { data: Record<string, unknown> }) {
const { heading, subheading, channels } = data as unknown as CTAData;
const { heading, subheading, channels } = data as unknown as CtaSectionData;

return (
<section className="w-full py-16 md:py-24">
Expand All @@ -69,8 +58,8 @@ export function CTASection({ data }: { data: Record<string, unknown> }) {
<a
key={channel.icon}
href={channel.href}
target="_blank"
rel="noopener noreferrer"
target={channel.target}
rel={channel.rel}
className="group flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-surface p-6 text-center transition-colors duration-200 hover:border-accent hover:bg-background"
>
<ChannelIcon icon={channel.icon} />
Expand Down
120 changes: 120 additions & 0 deletions src/lib/cta-channels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
enrichCtaChannel,
enrichCtaSectionData,
getCtaChannelLinkAttributes,
isThirdPartyHref,
} from "./cta-channels";

describe("isThirdPartyHref", () => {
it("treats LinkedIn as third-party", () => {
expect(isThirdPartyHref("https://www.linkedin.com/in/grant-lindsay-us/")).toBe(
true
);
});

it("treats mailto as third-party", () => {
expect(isThirdPartyHref("mailto:info@fastdogcoding.com")).toBe(true);
});

it("treats Cal.com as third-party", () => {
expect(isThirdPartyHref("https://cal.com/grant-lindsay-7wiujq/25min")).toBe(
true
);
});

it("treats Fast Dog Coding apps as first-party", () => {
expect(
isThirdPartyHref("https://candidate-concierge.fastdogcoding.com/")
).toBe(false);
expect(isThirdPartyHref("https://fastdogcoding.com/gallery")).toBe(false);
});

it("treats same-site paths as first-party", () => {
expect(isThirdPartyHref("/gallery")).toBe(false);
});
});

describe("getCtaChannelLinkAttributes", () => {
it("opens third-party links in a new tab with noopener noreferrer", () => {
expect(getCtaChannelLinkAttributes("https://www.linkedin.com/in/grant-lindsay-us/")).toEqual({
target: "_blank",
rel: "noopener noreferrer",
});
});

it("opens mailto links in a new tab with noopener noreferrer", () => {
expect(getCtaChannelLinkAttributes("mailto:info@fastdogcoding.com")).toEqual({
target: "_blank",
rel: "noopener noreferrer",
});
});

it("opens first-party apps in a new tab without rel", () => {
expect(
getCtaChannelLinkAttributes("https://candidate-concierge.fastdogcoding.com/")
).toEqual({
target: "_blank",
});
});
});

describe("enrichCtaSectionData", () => {
it("adds link attributes to each channel", () => {
const data = {
heading: "Let's Connect",
channels: [
{
label: "LinkedIn",
href: "https://www.linkedin.com/in/grant-lindsay-us/",
icon: "linkedin",
micro_copy: "Network",
},
{
label: "Candidate Concierge",
href: "https://candidate-concierge.fastdogcoding.com/",
icon: "ai",
micro_copy: "Ask questions",
},
],
};

const enriched = enrichCtaSectionData(data);

expect(enriched.channels).toEqual([
{
label: "LinkedIn",
href: "https://www.linkedin.com/in/grant-lindsay-us/",
icon: "linkedin",
micro_copy: "Network",
target: "_blank",
rel: "noopener noreferrer",
},
{
label: "Candidate Concierge",
href: "https://candidate-concierge.fastdogcoding.com/",
icon: "ai",
micro_copy: "Ask questions",
target: "_blank",
},
]);
});
});

describe("enrichCtaChannel", () => {
it("preserves existing channel fields", () => {
expect(
enrichCtaChannel({
label: "Email",
short_label: "Email",
href: "mailto:info@fastdogcoding.com",
icon: "email",
micro_copy: "Write us",
})
).toMatchObject({
short_label: "Email",
target: "_blank",
rel: "noopener noreferrer",
});
});
});
92 changes: 92 additions & 0 deletions src/lib/cta-channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* CTA channel link attributes
*
* Stored channel rows only carry content fields (label, href, etc.).
* Callers enrich channels at fetch time so UI components render target/rel
* without embedding link-classification logic.
*/

import type { CtaChannel, EnrichedCtaChannel } from "@/types";

const FIRST_PARTY_HOST = "fastdogcoding.com";

/** Protocols that delegate to an external app and are treated as third-party. */
const EXTERNAL_APP_PROTOCOLS = new Set(["mailto:", "tel:"]);

function isFirstPartyHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
return host === FIRST_PARTY_HOST || host.endsWith(`.${FIRST_PARTY_HOST}`);
}

/**
* Whether a href should receive rel="noopener noreferrer".
* All CTA channels open in a new tab; only third-party destinations get rel.
*/
export function isThirdPartyHref(href: string): boolean {
for (const protocol of EXTERNAL_APP_PROTOCOLS) {
if (href.startsWith(protocol)) return true;
}

if (href.startsWith("/") || href.startsWith("#")) {
return false;
}

try {
const { hostname } = new URL(href);
return !isFirstPartyHostname(hostname);
} catch {
return true;
}
}

/** Resolved link attributes for a CTA channel href. */
export function getCtaChannelLinkAttributes(href: string): {
target: "_blank";
rel?: "noopener noreferrer";
} {
const attributes: { target: "_blank"; rel?: "noopener noreferrer" } = {
target: "_blank",
};

if (isThirdPartyHref(href)) {
attributes.rel = "noopener noreferrer";
}

return attributes;
}

export function enrichCtaChannel(channel: CtaChannel): EnrichedCtaChannel {
return {
...channel,
...getCtaChannelLinkAttributes(channel.href),
};
}

/** Enrich channels inside a CTA section's JSON data blob. */
export function enrichCtaSectionData(
data: Record<string, unknown>
): Record<string, unknown> {
const { channels } = data;
if (!Array.isArray(channels)) return data;

return {
...data,
channels: channels.map((channel) =>
enrichCtaChannel(channel as CtaChannel)
),
};
}

/** Enrich section records whose type is `cta`. */
export function enrichSectionRecord<S extends Record<string, unknown>>(
section: S
): S {
if (section.type !== "cta" || !section.data || typeof section.data !== "object") {
return section;
}

return {
...section,
data: enrichCtaSectionData(section.data as Record<string, unknown>),
};
}
19 changes: 12 additions & 7 deletions src/lib/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* JSON and DateTime scalars are pass-through (Prisma handles serialization).
*/

import { enrichSectionRecord } from "@/lib/cta-channels";
import { prisma } from "@/lib/prisma";
import { GraphQLScalarType, Kind } from "graphql";
import type {
Expand Down Expand Up @@ -90,11 +91,13 @@ function flattenPageSections(page: {
const { pageSections, ...rest } = page;
return {
...rest,
sections: pageSections.map((ps) => ({
...ps.section,
sortOrder: ps.sortOrder,
displayHint: ps.displayHint,
})),
sections: pageSections.map((ps) =>
enrichSectionRecord({
...ps.section,
sortOrder: ps.sortOrder,
displayHint: ps.displayHint,
} as Record<string, unknown>)
),
};
}

Expand Down Expand Up @@ -181,19 +184,21 @@ export const resolvers = {
* Useful for direct section access (e.g., SiteFooter fetching CTA data).
*/
section: async (_parent: unknown, args: SectionQueryArgs) => {
return prisma.section.findUnique({
const section = await prisma.section.findUnique({
where: { slug: args.slug },
});
return section ? enrichSectionRecord(section) : null;
},

/**
* Fetch all sections of a given type.
* Useful for aggregation views (e.g., all testimonials).
*/
sectionsByType: async (_parent: unknown, args: SectionsByTypeArgs) => {
return prisma.section.findMany({
const sections = await prisma.section.findMany({
where: { type: args.type },
});
return sections.map(enrichSectionRecord);
},
},
};
22 changes: 22 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ export interface TestimonialData {
snippets: string[];
}

/** Stored CTA channel row (seed / JSONB). Link attrs are added at fetch time. */
export interface CtaChannel {
label: string;
short_label?: string;
href: string;
icon: string;
micro_copy: string;
}

/** CTA channel after fetch-time link enrichment. */
export interface EnrichedCtaChannel extends CtaChannel {
target: "_blank";
rel?: "noopener noreferrer";
}

/** CTA section data (JSONB) */
export interface CtaSectionData {
heading: string;
subheading?: string;
channels: EnrichedCtaChannel[];
}

// ── GraphQL Response Types ──

/** Shape returned by the page() GraphQL query */
Expand Down
Loading