feat(home): prova no topo, contato sem atrito e conversão medida
Home mais curta (hero, vitrine de clientes, sobre, serviços, IA resumida, depoimentos e contato), faixa de clientes e telas reais no hero, sem números do GitHub. Barra de cookies fina, WhatsApp fixo no celular, formulário com e-mail opcional e alternativa por e-mail, página /ia, eventos de e-mail, redes e currículo e conversão do Ads opcional por variável. Rodapé em 3 colunas. Remove componentes órfãos (LighthouseMetrics, Skills, Experience, contagem do GitHub).
This commit is contained in:
1 parent
6e1022b58c
commit
8d07b8c6bb
60 files changed
+4392
-2124
No files matched your search
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
GA_DISABLE_KEY,
|
||||
readConsent,
|
||||
trackEmailClick,
|
||||
trackSocialClick,
|
||||
trackWhatsAppOpen,
|
||||
} from "./analytics";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de lib/analytics.ts (complemento de analytics.test.ts):
|
||||
* modelo de rota de cada página (analyticsPagePath), cada condição que silencia o envio (sendEvent)
|
||||
* e a conversão do Google Ads (sendAdsConversion) com e sem etiqueta, com e sem consentimento.
|
||||
*/
|
||||
|
||||
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
localStorage.setItem(CONSENT_KEY, "granted");
|
||||
delete window[GA_DISABLE_KEY];
|
||||
window.gtag = jest.fn();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
delete window.gtag;
|
||||
delete process.env[ADS_LABEL];
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
function gtagCalls() {
|
||||
return (window.gtag as jest.Mock).mock.calls;
|
||||
}
|
||||
|
||||
describe("analyticsPagePath (via the page_path sent with each event)", () => {
|
||||
it.each([
|
||||
["/", "/"],
|
||||
["/servicos", "/servicos"],
|
||||
["/cases", "/cases"],
|
||||
["/curriculo", "/curriculo"],
|
||||
["/privacidade", "/privacidade"],
|
||||
["/ia", "/ia"],
|
||||
["/cases/", "/cases"],
|
||||
["/ia/?utm=x#y", "/ia"],
|
||||
["/servicos/criacao-de-sites", "/servicos/[slug]"],
|
||||
["/servicos/qualquer/coisa/mais", "/servicos/[slug]"],
|
||||
["/cases/sev-modaintima", "/cases/[slug]"],
|
||||
["/cases/ana%40example.com?x=1", "/cases/[slug]"],
|
||||
["/servicos-extra", "/other"],
|
||||
["/cases-extra/abc", "/other"],
|
||||
["/nao-existe", "/other"],
|
||||
])("path: location %s -> page_path %s", (location, expected) => {
|
||||
window.history.replaceState({}, "", location);
|
||||
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
|
||||
expect(gtagCalls()).toHaveLength(1);
|
||||
expect(gtagCalls()[0][2]).toMatchObject({
|
||||
page_path: expected,
|
||||
page_location: `https://evandro.dev.br${expected}`,
|
||||
page_referrer: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEvent: cada condição que silencia o envio", () => {
|
||||
it("path: gtag exists but is not a function -> nothing sent, no conversion, no error", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
window.gtag = "not-a-function" as never;
|
||||
|
||||
expect(() =>
|
||||
trackWhatsAppOpen({ service: "S", placement: "p" }),
|
||||
).not.toThrow();
|
||||
expect(() => trackEmailClick({ placement: "p" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("path: stored consent is an unknown value -> treated as no consent", () => {
|
||||
localStorage.setItem(CONSENT_KEY, "yes");
|
||||
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
|
||||
expect(readConsent()).toBeNull();
|
||||
expect(gtagCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: storage throws -> readConsent returns null and nothing is sent", () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage blocked");
|
||||
});
|
||||
|
||||
expect(readConsent()).toBeNull();
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
|
||||
expect(gtagCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: event counted -> one gtag call carrying the static params and the route template", () => {
|
||||
window.history.replaceState({}, "", "/cases/sev-modaintima?q=1#x");
|
||||
|
||||
trackEmailClick({ placement: "footer" });
|
||||
|
||||
expect(gtagCalls()).toEqual([
|
||||
[
|
||||
"event",
|
||||
"email_click",
|
||||
{
|
||||
send_to: "G-QTT9QT0T3S",
|
||||
placement: "footer",
|
||||
page_path: "/cases/[slug]",
|
||||
page_location: "https://evandro.dev.br/cases/[slug]",
|
||||
page_referrer: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendAdsConversion: etiqueta x consentimento", () => {
|
||||
it.each([
|
||||
["is unset", undefined],
|
||||
["is an empty string", ""],
|
||||
])(
|
||||
"path: label %s, consent granted -> only the GA4 event goes out",
|
||||
(_l, label) => {
|
||||
if (label !== undefined) process.env[ADS_LABEL] = label;
|
||||
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
trackEmailClick({ placement: "footer" });
|
||||
|
||||
expect(gtagCalls().map(([, name]) => name)).toEqual([
|
||||
"whatsapp_open",
|
||||
"email_click",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: label set, consent denied -> nothing at all, not even the conversion", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
localStorage.setItem(CONSENT_KEY, "denied");
|
||||
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
trackEmailClick({ placement: "footer" });
|
||||
|
||||
expect(gtagCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: label set, consent granted -> the conversion uses the Ads id and the label", () => {
|
||||
process.env[ADS_LABEL] = "xyz-789";
|
||||
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
|
||||
expect(gtagCalls().map(([, name]) => name)).toEqual([
|
||||
"whatsapp_open",
|
||||
"conversion",
|
||||
]);
|
||||
expect(gtagCalls()[1][2]).toEqual({
|
||||
send_to: "AW-18432694647/xyz-789",
|
||||
});
|
||||
});
|
||||
|
||||
it("path: the conversion call throws -> the click handler still returns normally", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
window.gtag = jest.fn((_command: unknown, name: unknown) => {
|
||||
if (name === "conversion") throw new Error("ads blocked");
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" }),
|
||||
).not.toThrow();
|
||||
expect(gtagCalls().map(([, name]) => name)).toEqual([
|
||||
"whatsapp_open",
|
||||
"conversion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("path: gtag disappears right after the event was counted -> the conversion is skipped silently", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
const gtag = jest.fn(() => {
|
||||
delete window.gtag;
|
||||
});
|
||||
window.gtag = gtag;
|
||||
|
||||
expect(() => trackEmailClick({ placement: "footer" })).not.toThrow();
|
||||
expect(gtag).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* Caminhos de lib/analytics.ts quando não existe `window` (renderização no servidor): nenhum rastreio,
|
||||
* nenhuma conversão e nenhum erro. O setup do projeto exige o jsdom e o `window` dele não pode ser
|
||||
* escondido, então o próprio fonte é transpilado e executado com `window` e `localStorage` indefinidos.
|
||||
*/
|
||||
|
||||
type AnalyticsModule = typeof import("./analytics");
|
||||
|
||||
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
|
||||
|
||||
function loadWithoutWindow(storage?: Storage): AnalyticsModule {
|
||||
const source = readFileSync(join(process.cwd(), "lib/analytics.ts"), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
});
|
||||
const loaded = { exports: {} as AnalyticsModule };
|
||||
new Function("module", "exports", "window", "localStorage", outputText)(
|
||||
loaded,
|
||||
loaded.exports,
|
||||
undefined,
|
||||
storage,
|
||||
);
|
||||
return loaded.exports;
|
||||
}
|
||||
|
||||
let gtag: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||
gtag = jest.fn();
|
||||
window.gtag = gtag;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
delete window.gtag;
|
||||
delete process.env[ADS_LABEL];
|
||||
});
|
||||
|
||||
describe("analytics without a window", () => {
|
||||
it("path: every tracker is silent and never throws, even with granted consent and a conversion label", () => {
|
||||
// o consentimento está "granted" no storage real: só a ausência de `window` impede o envio
|
||||
const analytics = loadWithoutWindow(localStorage);
|
||||
|
||||
expect(() => {
|
||||
analytics.trackWhatsAppOpen({
|
||||
service: "Contato",
|
||||
placement: "floating",
|
||||
});
|
||||
analytics.trackEmailClick({ placement: "footer" });
|
||||
analytics.trackSocialClick({ network: "linkedin" });
|
||||
analytics.trackCvDownload({ placement: "curriculo" });
|
||||
}).not.toThrow();
|
||||
expect(gtag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: readConsent finds no storage -> null", () => {
|
||||
expect(loadWithoutWindow().readConsent()).toBeNull();
|
||||
});
|
||||
|
||||
it("path: openCookiePreferences has no window to notify -> returns without error", () => {
|
||||
const listener = jest.fn();
|
||||
window.addEventListener("evandro:open-cookie-preferences", listener);
|
||||
|
||||
expect(() => loadWithoutWindow().openCookiePreferences()).not.toThrow();
|
||||
|
||||
window.removeEventListener("evandro:open-cookie-preferences", listener);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("control: the same source with a window does dispatch the reopen event", () => {
|
||||
const listener = jest.fn();
|
||||
window.addEventListener("evandro:open-cookie-preferences", listener);
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "lib/analytics.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
});
|
||||
const loaded = { exports: {} as AnalyticsModule };
|
||||
new Function("module", "exports", "window", outputText)(
|
||||
loaded,
|
||||
loaded.exports,
|
||||
window,
|
||||
);
|
||||
|
||||
loaded.exports.openCookiePreferences();
|
||||
|
||||
window.removeEventListener("evandro:open-cookie-preferences", listener);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
GA_DISABLE_KEY,
|
||||
OPEN_COOKIE_PREFERENCES_EVENT,
|
||||
openCookiePreferences,
|
||||
trackCvDownload,
|
||||
trackEmailClick,
|
||||
trackSocialClick,
|
||||
trackWhatsAppOpen,
|
||||
} from "./analytics";
|
||||
|
||||
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
|
||||
|
||||
const common = {
|
||||
send_to: "G-QTT9QT0T3S",
|
||||
page_path: "/",
|
||||
page_location: "https://evandro.dev.br/",
|
||||
page_referrer: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
localStorage.setItem(CONSENT_KEY, "granted");
|
||||
delete window[GA_DISABLE_KEY];
|
||||
window.gtag = jest.fn();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
delete window.gtag;
|
||||
delete process.env[ADS_LABEL];
|
||||
});
|
||||
|
||||
describe("events", () => {
|
||||
it("sends email_click, social_click and cv_download with static labels only", () => {
|
||||
trackEmailClick({ placement: "footer" });
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
trackCvDownload({ placement: "curriculo" });
|
||||
|
||||
expect(window.gtag).toHaveBeenNthCalledWith(1, "event", "email_click", {
|
||||
...common,
|
||||
placement: "footer",
|
||||
});
|
||||
expect(window.gtag).toHaveBeenNthCalledWith(2, "event", "social_click", {
|
||||
...common,
|
||||
network: "linkedin",
|
||||
});
|
||||
expect(window.gtag).toHaveBeenNthCalledWith(3, "event", "cv_download", {
|
||||
...common,
|
||||
placement: "curriculo",
|
||||
});
|
||||
expect(window.gtag).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("reports the floating button as a whatsapp_open placement", () => {
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
|
||||
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
|
||||
...common,
|
||||
service: "Contato",
|
||||
placement: "floating",
|
||||
});
|
||||
});
|
||||
|
||||
it("knows the /ia page instead of lumping it into /other", () => {
|
||||
window.history.replaceState({}, "", "/ia?x=1#y");
|
||||
trackEmailClick({ placement: "ia" });
|
||||
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
"event",
|
||||
"email_click",
|
||||
expect.objectContaining({
|
||||
page_path: "/ia",
|
||||
page_location: "https://evandro.dev.br/ia",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without consent", () => localStorage.removeItem(CONSENT_KEY)],
|
||||
["after refusal", () => localStorage.setItem(CONSENT_KEY, "denied")],
|
||||
[
|
||||
"when GA is disabled",
|
||||
() => Object.assign(window, { [GA_DISABLE_KEY]: true }),
|
||||
],
|
||||
["when gtag is missing", () => delete window.gtag],
|
||||
])("sends nothing %s", (_label, arrange) => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
const gtag = window.gtag as jest.Mock;
|
||||
arrange();
|
||||
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
trackEmailClick({ placement: "footer" });
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
trackCvDownload({ placement: "curriculo" });
|
||||
|
||||
expect(gtag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing outside production", () => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "development");
|
||||
trackEmailClick({ placement: "footer" });
|
||||
expect(window.gtag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws when gtag fails", () => {
|
||||
window.gtag = jest.fn(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
expect(() => trackEmailClick({ placement: "footer" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Ads conversion", () => {
|
||||
it("is not sent without the conversion label", () => {
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
trackEmailClick({ placement: "footer" });
|
||||
|
||||
expect(window.gtag).toHaveBeenCalledTimes(2);
|
||||
for (const [, name] of (window.gtag as jest.Mock).mock.calls) {
|
||||
expect(name).not.toBe("conversion");
|
||||
}
|
||||
});
|
||||
|
||||
it("goes out next to whatsapp_open and email_click when the label exists", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
|
||||
trackEmailClick({ placement: "footer" });
|
||||
|
||||
const calls = (window.gtag as jest.Mock).mock.calls;
|
||||
expect(calls.map(([, name]) => name)).toEqual([
|
||||
"whatsapp_open",
|
||||
"conversion",
|
||||
"email_click",
|
||||
"conversion",
|
||||
]);
|
||||
expect(calls[1]).toEqual([
|
||||
"event",
|
||||
"conversion",
|
||||
{ send_to: "AW-18432694647/abc123" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not turn social clicks or CV downloads into Ads conversions", () => {
|
||||
process.env[ADS_LABEL] = "abc123";
|
||||
|
||||
trackSocialClick({ network: "linkedin" });
|
||||
trackCvDownload({ placement: "curriculo" });
|
||||
|
||||
expect(window.gtag).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cookie preferences", () => {
|
||||
it("asks the consent bar to reopen through a window event", () => {
|
||||
const listener = jest.fn();
|
||||
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, listener);
|
||||
openCookiePreferences();
|
||||
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+63
-12
@@ -1,4 +1,5 @@
|
||||
export const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
|
||||
export const GOOGLE_ADS_ID = "AW-18432694647";
|
||||
export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
|
||||
export const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||
|
||||
@@ -30,6 +31,7 @@ function analyticsPagePath(): string {
|
||||
case "/cases":
|
||||
case "/curriculo":
|
||||
case "/privacidade":
|
||||
case "/ia":
|
||||
return path;
|
||||
default:
|
||||
// Route templates keep arbitrary slugs and personal data out of metrics.
|
||||
@@ -39,16 +41,14 @@ function analyticsPagePath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures an attempted handoff, not an opened app, delivered message or lead.
|
||||
type EventParams = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Sends one GA4 event only while the visitor has granted consent; every other state is silent.
|
||||
* Callers must supply static labels, never form values or destination URLs.
|
||||
* Returns true when the event was handed to gtag, so the Ads conversion is only sent for a counted click.
|
||||
*/
|
||||
export function trackWhatsAppOpen({
|
||||
service,
|
||||
placement,
|
||||
}: {
|
||||
service: string;
|
||||
placement: string;
|
||||
}): void {
|
||||
function sendEvent(name: string, params: EventParams): boolean {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
typeof window === "undefined" ||
|
||||
@@ -56,21 +56,72 @@ export function trackWhatsAppOpen({
|
||||
window[GA_DISABLE_KEY] === true ||
|
||||
typeof window.gtag !== "function"
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pagePath = analyticsPagePath();
|
||||
window.gtag("event", "whatsapp_open", {
|
||||
window.gtag("event", name, {
|
||||
send_to: GA_MEASUREMENT_ID,
|
||||
service,
|
||||
placement,
|
||||
...params,
|
||||
page_path: pagePath,
|
||||
// Override gtag's URL defaults so query/hash/referrer data cannot leak.
|
||||
page_location: `https://evandro.dev.br${pagePath}`,
|
||||
page_referrer: "",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// Analytics must never interrupt navigation. Dropped events are not replayed.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Ads conversion for the same click, only when a conversion label is configured.
|
||||
* Without NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL nothing is sent to Ads.
|
||||
*/
|
||||
function sendAdsConversion(): void {
|
||||
const label = process.env.NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL;
|
||||
if (!label) return;
|
||||
try {
|
||||
window.gtag?.("event", "conversion", {
|
||||
send_to: `${GOOGLE_ADS_ID}/${label}`,
|
||||
});
|
||||
} catch {
|
||||
// Same rule as above: a blocked tag never interrupts navigation.
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures an attempted handoff, not an opened app, delivered message or lead. */
|
||||
export function trackWhatsAppOpen({
|
||||
service,
|
||||
placement,
|
||||
}: {
|
||||
service: string;
|
||||
placement: string;
|
||||
}): void {
|
||||
if (sendEvent("whatsapp_open", { service, placement })) sendAdsConversion();
|
||||
}
|
||||
|
||||
/** Measures a click on a mailto: link, not a sent e-mail. */
|
||||
export function trackEmailClick({ placement }: { placement: string }): void {
|
||||
if (sendEvent("email_click", { placement })) sendAdsConversion();
|
||||
}
|
||||
|
||||
/** Measures a click on a social profile; `network` is a static name such as "linkedin". */
|
||||
export function trackSocialClick({ network }: { network: string }): void {
|
||||
sendEvent("social_click", { network });
|
||||
}
|
||||
|
||||
/** Measures a click on the résumé PDF download. */
|
||||
export function trackCvDownload({ placement }: { placement: string }): void {
|
||||
sendEvent("cv_download", { placement });
|
||||
}
|
||||
|
||||
/** O rodapé pede a reabertura da barra de cookies; quem a monta (GoogleAnalytics) escuta este evento. */
|
||||
export const OPEN_COOKIE_PREFERENCES_EVENT = "evandro:open-cookie-preferences";
|
||||
|
||||
export function openCookiePreferences(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(OPEN_COOKIE_PREFERENCES_EVENT));
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
countOwnRepos,
|
||||
GITHUB_STATS_REVALIDATE_SECONDS,
|
||||
loadGithubRepoCounts,
|
||||
loadGithubStatsSnapshot,
|
||||
} from "./github-stats";
|
||||
|
||||
const repo = (
|
||||
overrides: Partial<{ private: boolean; fork: boolean; archived: boolean }>,
|
||||
) => ({ private: false, fork: false, archived: false, ...overrides });
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const noSnapshot = async () => null;
|
||||
const snapshot = { total: 68, public: 25, private: 43 };
|
||||
|
||||
describe("countOwnRepos", () => {
|
||||
it("counts public and private repos and ignores forks and archived ones", () => {
|
||||
const counts = countOwnRepos([
|
||||
repo({}),
|
||||
repo({}),
|
||||
repo({ private: true }),
|
||||
repo({ fork: true }),
|
||||
repo({ private: true, fork: true }),
|
||||
repo({ archived: true }),
|
||||
repo({ private: true, archived: true }),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 3, public: 2, private: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadGithubRepoCounts", () => {
|
||||
it("returns null without a token and without a snapshot, and never calls the API", async () => {
|
||||
const fetchImpl = jest.fn();
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts(undefined, fetchImpl, noSnapshot),
|
||||
).resolves.toBeNull();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the last known snapshot without a token", async () => {
|
||||
const fetchImpl = jest.fn();
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts(undefined, fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the live count over the snapshot", async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([repo({}), repo({ private: true })]));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual({ total: 2, public: 1, private: 1 });
|
||||
});
|
||||
|
||||
it("sends the token only to the GitHub API and revalidates daily", async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})]));
|
||||
|
||||
await loadGithubRepoCounts("secret-token", fetchImpl, noSnapshot);
|
||||
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(new URL(url).origin).toBe("https://api.github.com");
|
||||
expect(url).toContain("affiliation=owner");
|
||||
expect(url).toContain("visibility=all");
|
||||
expect(init.headers.authorization).toBe("Bearer secret-token");
|
||||
expect(init.next.revalidate).toBe(GITHUB_STATS_REVALIDATE_SECONDS);
|
||||
expect(GITHUB_STATS_REVALIDATE_SECONDS).toBe(86_400);
|
||||
});
|
||||
|
||||
it("walks every page before counting", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, () => repo({}));
|
||||
const secondPage = [repo({ private: true }), repo({ fork: true })];
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||
.mockResolvedValueOnce(jsonResponse(secondPage));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, noSnapshot),
|
||||
).resolves.toEqual({
|
||||
total: 101,
|
||||
public: 100,
|
||||
private: 1,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the snapshot when the API rejects the token", async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("bad", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("uses the snapshot on network failure instead of breaking the page", async () => {
|
||||
const fetchImpl = jest.fn().mockRejectedValue(new Error("offline"));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadGithubStatsSnapshot", () => {
|
||||
it("reads a coherent snapshot from data/github-stats.json", async () => {
|
||||
const counts = await loadGithubStatsSnapshot();
|
||||
|
||||
expect(counts).not.toBeNull();
|
||||
expect(counts?.total).toBe((counts?.public ?? 0) + (counts?.private ?? 0));
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Contagem de repositórios próprios no GitHub (públicos + privados), consultada só no servidor.
|
||||
* O token (GITHUB_STATS_TOKEN, fine-grained, somente leitura de metadados) nunca recebe prefixo NEXT_PUBLIC_
|
||||
* e este módulo só é importado por componentes de servidor: ele não chega ao navegador.
|
||||
*
|
||||
* Sem token, ou se a API falhar, vale o último retrato conhecido em data/github-stats.json.
|
||||
*/
|
||||
|
||||
export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const REPOS_ENDPOINT = "https://api.github.com/user/repos";
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 20;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface GithubRepoCounts {
|
||||
total: number;
|
||||
public: number;
|
||||
private: number;
|
||||
}
|
||||
|
||||
interface RepoSummary {
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
type CachedRequestInit = RequestInit & { next: { revalidate: number } };
|
||||
type FetchLike = (url: string, init: CachedRequestInit) => Promise<Response>;
|
||||
|
||||
/** Só repositórios meus: forks e arquivados ficam de fora. */
|
||||
export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts {
|
||||
let publicCount = 0;
|
||||
let privateCount = 0;
|
||||
for (const repo of repos) {
|
||||
if (repo.fork || repo.archived) continue;
|
||||
if (repo.private) privateCount += 1;
|
||||
else publicCount += 1;
|
||||
}
|
||||
return {
|
||||
total: publicCount + privateCount,
|
||||
public: publicCount,
|
||||
private: privateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGithubRepoCounts(
|
||||
token: string | undefined,
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<GithubRepoCounts | null> {
|
||||
if (!token) return null;
|
||||
|
||||
const repos: RepoSummary[] = [];
|
||||
try {
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const url = `${REPOS_ENDPOINT}?affiliation=owner&visibility=all&per_page=${PAGE_SIZE}&page=${page}`;
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "evandro-dev-portfolio",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
next: { revalidate: GITHUB_STATS_REVALIDATE_SECONDS },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const batch = (await response.json()) as RepoSummary[];
|
||||
if (!Array.isArray(batch)) return null;
|
||||
repos.push(
|
||||
...batch.map(({ private: isPrivate, fork, archived }) => ({
|
||||
private: isPrivate,
|
||||
fork,
|
||||
archived,
|
||||
})),
|
||||
);
|
||||
if (batch.length < PAGE_SIZE) return countOwnRepos(repos);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */
|
||||
export async function loadGithubStatsSnapshot(): Promise<GithubRepoCounts | null> {
|
||||
try {
|
||||
const path = resolve(process.cwd(), "data/github-stats.json");
|
||||
const raw = JSON.parse(await readFile(path, "utf8")) as GithubRepoCounts;
|
||||
const counts = [raw.total, raw.public, raw.private];
|
||||
if (!counts.every((n) => Number.isInteger(n) && n >= 0)) return null;
|
||||
if (raw.total !== raw.public + raw.private) return null;
|
||||
return { total: raw.total, public: raw.public, private: raw.private };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGithubRepoCounts(
|
||||
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
readSnapshot: () => Promise<GithubRepoCounts | null> = loadGithubStatsSnapshot,
|
||||
): Promise<GithubRepoCounts | null> {
|
||||
return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { portfolio } from "./portfolio-data";
|
||||
|
||||
/** Projetos feitos para clientes aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
|
||||
export type ProjectKind = "cliente" | "laboratorio";
|
||||
|
||||
export interface Testimonial {
|
||||
quote: string;
|
||||
name: string;
|
||||
role: string;
|
||||
company: string;
|
||||
/** id do projeto em public/portfolio-data.json, para ligar o depoimento ao case. */
|
||||
projectId?: string;
|
||||
/** Caminho em /public; sem foto, mostra as iniciais. */
|
||||
photo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depoimentos reais, enviados e autorizados pelo próprio cliente. Nunca escrever um depoimento em nome de alguém.
|
||||
* Lista vazia: a seção de depoimentos não aparece.
|
||||
*/
|
||||
export const testimonials: Testimonial[] = [];
|
||||
|
||||
export function isClientProject(project: { kind?: string }) {
|
||||
return project.kind === "cliente";
|
||||
}
|
||||
|
||||
/** Clientes na ordem dos projetos, para a faixa "Já entregue para". */
|
||||
export const clientNames = portfolio.projects
|
||||
.filter(isClientProject)
|
||||
.map((project) => ({ id: project.id, name: project.title }));
|
||||
+14
-3
@@ -2,9 +2,20 @@ export const CV_URL = "/Profile.pdf";
|
||||
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
|
||||
|
||||
export const NAVIGATION_ITEMS = [
|
||||
{ href: "/#modalities", label: "Soluções" },
|
||||
{ href: "/cases", label: "Projetos" },
|
||||
{ href: "/#projects", label: "Projetos" },
|
||||
{ href: "/#process", label: "Como trabalho" },
|
||||
{ href: "/#about", label: "Sobre" },
|
||||
{ href: "/#experience", label: "Experiência" },
|
||||
{ href: "/cases", label: "Cases" },
|
||||
{ href: "/servicos", label: "Serviços" },
|
||||
] as const;
|
||||
|
||||
export const CONTACT_EMAIL = "[email protected]";
|
||||
export const LINKEDIN_URL = "https://www.linkedin.com/in/evandrodevbr/";
|
||||
export const INSTAGRAM_URL = "https://www.instagram.com/evandrodevbr/";
|
||||
|
||||
/** Serviço do hub, como a home o recebe: app/page.tsx lê lib/site-content.ts (que não pode ir para componentes de cliente). */
|
||||
export interface ServiceLink {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
Reference in new issue
Block a user