feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
1 parent
c9a0b80656
commit
e80f73d075
52 files changed
+2593
-439
No files matched your search
@@ -0,0 +1,2 @@
|
||||
// "server-only" lança fora do servidor do Next; nos testes (jsdom) o módulo não faz nada.
|
||||
export {};
|
||||
+74
-8
@@ -1,16 +1,82 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { render } from "@testing-library/react";
|
||||
import { OgCard, readProjectOgBackground } from "./og-card";
|
||||
import { OG_IMAGE_MAX_BYTES, OgCard, readProjectOgImage } from "./og-card";
|
||||
import { PROJECT_VIDEO_IDS } from "./project-showcase";
|
||||
|
||||
describe("readProjectOgBackground", () => {
|
||||
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
|
||||
const uri = readProjectOgBackground("nutriscan");
|
||||
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
|
||||
expect((uri ?? "").length).toBeGreaterThan(5_000);
|
||||
jest.mock("node:fs", () => {
|
||||
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
|
||||
return { ...actual, statSync: jest.fn(actual.statSync) };
|
||||
});
|
||||
|
||||
const mockedStatSync = jest.mocked(statSync);
|
||||
const actualStatSync =
|
||||
jest.requireActual<typeof import("node:fs")>("node:fs").statSync;
|
||||
|
||||
afterEach(() => {
|
||||
mockedStatSync.mockImplementation(actualStatSync);
|
||||
});
|
||||
|
||||
/** Largura e altura de um JPEG, lidas do primeiro marcador de quadro (SOF). */
|
||||
function jpegSize(image: Buffer) {
|
||||
let offset = 2;
|
||||
while (offset < image.length) {
|
||||
const marker = image[offset + 1];
|
||||
const length = image.readUInt16BE(offset + 2);
|
||||
if (
|
||||
marker >= 0xc0 &&
|
||||
marker <= 0xcf &&
|
||||
marker !== 0xc4 &&
|
||||
marker !== 0xc8
|
||||
) {
|
||||
return {
|
||||
height: image.readUInt16BE(offset + 5),
|
||||
width: image.readUInt16BE(offset + 7),
|
||||
};
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
throw new Error("JPEG sem marcador de quadro");
|
||||
}
|
||||
|
||||
describe("readProjectOgImage", () => {
|
||||
it("returns the recorded cover of a showcase project as it is, in JPEG", () => {
|
||||
const image = readProjectOgImage("nutriscan");
|
||||
expect(image?.subarray(0, 2).toString("hex")).toBe("ffd8");
|
||||
expect(image?.length).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
it("serves every recorded cover below 150 KB and in the 1200x630 card size", () => {
|
||||
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
|
||||
for (const id of PROJECT_VIDEO_IDS) {
|
||||
const image = readProjectOgImage(id);
|
||||
expect(image).toBeDefined();
|
||||
expect(image?.length ?? Infinity).toBeLessThan(150_000);
|
||||
expect(jpegSize(image as Buffer)).toEqual({ width: 1200, height: 630 });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for a cover that weighs the limit or more, so the plain card is used", () => {
|
||||
mockedStatSync.mockReturnValue({
|
||||
size: OG_IMAGE_MAX_BYTES,
|
||||
} as ReturnType<typeof statSync>);
|
||||
expect(readProjectOgImage("nutriscan")).toBeUndefined();
|
||||
|
||||
mockedStatSync.mockReturnValue({
|
||||
size: OG_IMAGE_MAX_BYTES - 1,
|
||||
} as ReturnType<typeof statSync>);
|
||||
expect(readProjectOgImage("nutriscan")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for projects without a recorded cover", () => {
|
||||
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
|
||||
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
|
||||
expect(readProjectOgImage("twins-lab")).toBeUndefined();
|
||||
expect(readProjectOgImage("../../etc/passwd")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the file is missing on disk", () => {
|
||||
mockedStatSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(readProjectOgImage("nutriscan")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-5
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import "server-only";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
|
||||
|
||||
@@ -8,13 +9,24 @@ const brandMark = `data:image/png;base64,${readFileSync(
|
||||
|
||||
export const OG_SIZE = { width: 1200, height: 630 } as const;
|
||||
export const OG_CONTENT_TYPE = "image/png";
|
||||
export const OG_PHOTO_CONTENT_TYPE = "image/jpeg";
|
||||
|
||||
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
|
||||
export function readProjectOgBackground(id: string): string | undefined {
|
||||
/** Teto de uma imagem de compartilhamento: quem recebe o link baixa a imagem a cada prévia, então ela precisa ser leve. */
|
||||
export const OG_IMAGE_MAX_BYTES = 150_000;
|
||||
|
||||
/**
|
||||
* Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), usada como está na imagem de compartilhamento.
|
||||
* `undefined` se não existir ou se pesar o teto ou mais: nesse caso quem chama gera o cartão simples, sem imagem de fundo.
|
||||
*/
|
||||
export function readProjectOgImage(id: string): Buffer | undefined {
|
||||
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
|
||||
const path = join(process.cwd(), "public", getOgImagePath(id));
|
||||
if (!existsSync(path)) return undefined;
|
||||
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
|
||||
try {
|
||||
if (statSync(path).size >= OG_IMAGE_MAX_BYTES) return undefined;
|
||||
return readFileSync(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function OgCard({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { portfolio } from "./portfolio-data";
|
||||
|
||||
describe("public/portfolio-data.json", () => {
|
||||
it("writes the Innovo company name with the correct accents and case", () => {
|
||||
const innovo = portfolio.experiences.find(
|
||||
({ id }) => id === "innovo-tecnologia",
|
||||
);
|
||||
expect(innovo?.company).toBe("Innovo Tecnologia da Informação Ltda.");
|
||||
expect(JSON.stringify(portfolio)).not.toMatch(/Informacao|Da Informa/);
|
||||
});
|
||||
|
||||
it("keeps the kind of every project, which separates client work from the laboratory", () => {
|
||||
expect(
|
||||
portfolio.projects.map(({ id, kind }) => [id, kind] as const),
|
||||
).toEqual([
|
||||
["sev-modaintima", "cliente"],
|
||||
["lavacao-047", "cliente"],
|
||||
["nutriscan", "laboratorio"],
|
||||
["twins-lab", "laboratorio"],
|
||||
["reddit-explorer", "laboratorio"],
|
||||
["sc-plus", "cliente"],
|
||||
["gcc-installer", "laboratorio"],
|
||||
["as-servicos", "cliente"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes each project in a subtitle a business owner understands", () => {
|
||||
const subtitles = Object.fromEntries(
|
||||
portfolio.projects.map(({ id, subtitle }) => [id, subtitle]),
|
||||
);
|
||||
expect(subtitles["sc-plus"]).toBe(
|
||||
"Site institucional com áreas, projetos e contato",
|
||||
);
|
||||
expect(subtitles["gcc-installer"]).toBe(
|
||||
"Instalador do compilador GCC para Windows",
|
||||
);
|
||||
expect(Object.values(subtitles).join(" ")).not.toMatch(
|
||||
/Next\.js|MongoDB|MinGW|Gêmeo Digital|gerada em código/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import "server-only";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
|
||||
+66
-1
@@ -1,15 +1,23 @@
|
||||
import sitemap from "../app/sitemap";
|
||||
import {
|
||||
buildBreadcrumbList,
|
||||
buildCaseJsonLd,
|
||||
buildPageSocialMetadata,
|
||||
buildPersonJsonLd,
|
||||
buildProfilePageJsonLd,
|
||||
buildServiceJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
getCaseImageUrl,
|
||||
SEO_IDS,
|
||||
twitterHandle,
|
||||
} from "./seo";
|
||||
import { caseStudies, servicePages, siteName, siteUrl } from "./site-content";
|
||||
import {
|
||||
caseStudies,
|
||||
isClientCase,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "./site-content";
|
||||
|
||||
const representativeService = servicePages[0];
|
||||
if (!representativeService) throw new Error("Missing representative service");
|
||||
@@ -89,6 +97,63 @@ it("builds a canonical service with only the verified service area", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
it("describes a client case as a CreativeWork made by the person for the client", () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
|
||||
expect(buildCaseJsonLd(caseStudy)).toEqual({
|
||||
"@type": "CreativeWork",
|
||||
"@id": `${pageUrl}#creative-work`,
|
||||
name: "SEV Moda Íntima: Catálogo digital e loja virtual",
|
||||
description: caseStudy.metaDescription,
|
||||
url: pageUrl,
|
||||
image: `${pageUrl}/opengraph-image/${caseStudy.id}`,
|
||||
inLanguage: "pt-BR",
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
sourceOrganization: {
|
||||
"@type": "Organization",
|
||||
name: "SEV Moda Íntima",
|
||||
url: "https://sevmodaintima.com.br",
|
||||
},
|
||||
keywords:
|
||||
"Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a CreativeWork for every case, with the client only on client work", () => {
|
||||
for (const caseStudy of caseStudies) {
|
||||
const work = buildCaseJsonLd(caseStudy);
|
||||
expect(work).toMatchObject({
|
||||
"@type": "CreativeWork",
|
||||
url: `${siteUrl}/cases/${caseStudy.id}`,
|
||||
image: getCaseImageUrl(caseStudy.id),
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
});
|
||||
expect(work.name).toContain(caseStudy.title);
|
||||
expect(work.description.length).toBeGreaterThan(40);
|
||||
expect(work.keywords).toBe(caseStudy.stack.join(", "));
|
||||
expect("sourceOrganization" in work).toBe(isClientCase(caseStudy));
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the client address when a client case has no public site", () => {
|
||||
const work = buildCaseJsonLd(getCase("as-servicos")) as {
|
||||
url: string;
|
||||
sourceOrganization?: Record<string, unknown>;
|
||||
};
|
||||
expect(work.sourceOrganization).toStrictEqual({
|
||||
"@type": "Organization",
|
||||
name: "AS Serviços",
|
||||
});
|
||||
expect(work.url).toBe(`${siteUrl}/cases/as-servicos`);
|
||||
});
|
||||
|
||||
it("builds contiguous breadcrumbs from readonly tuples", () => {
|
||||
const pageUrl = `${siteUrl}/servicos/x`;
|
||||
const items = [
|
||||
|
||||
+38
-1
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { type ServicePageContent, siteName, siteUrl } from "./site-content";
|
||||
import {
|
||||
type CaseStudyContent,
|
||||
isClientCase,
|
||||
type ServicePageContent,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "./site-content";
|
||||
|
||||
export const twitterHandle = "@evandrodevbr";
|
||||
|
||||
@@ -78,6 +84,37 @@ export function buildServiceJsonLd(service: ServicePageContent) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** Imagem de compartilhamento do case: a mesma que as redes recebem em `og:image` (rota opengraph-image do case). */
|
||||
export function getCaseImageUrl(id: string) {
|
||||
return `${siteUrl}/cases/${id}/opengraph-image/${id}`;
|
||||
}
|
||||
|
||||
/** Trabalho do case como `CreativeWork`: autor ligado à pessoa e, nos projetos de cliente, o cliente em nome de quem foi feito. */
|
||||
export function buildCaseJsonLd(caseStudy: CaseStudyContent) {
|
||||
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
|
||||
return {
|
||||
"@type": "CreativeWork",
|
||||
"@id": `${pageUrl}#creative-work`,
|
||||
name: `${caseStudy.title}: ${caseStudy.subtitle}`,
|
||||
description: caseStudy.metaDescription,
|
||||
url: pageUrl,
|
||||
image: getCaseImageUrl(caseStudy.id),
|
||||
inLanguage: "pt-BR",
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
...(isClientCase(caseStudy)
|
||||
? {
|
||||
sourceOrganization: {
|
||||
"@type": "Organization",
|
||||
name: caseStudy.title,
|
||||
...(caseStudy.demoUrl ? { url: caseStudy.demoUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
keywords: caseStudy.stack.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBreadcrumbList(
|
||||
pageUrl: string,
|
||||
items: readonly (readonly [name: string, url: string])[],
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(__dirname, "..");
|
||||
|
||||
describe("fronteira entre servidor e cliente", () => {
|
||||
it.each([
|
||||
"app/cases/_components/case-media.ts",
|
||||
"lib/og-card.tsx",
|
||||
"lib/portfolio-data.server.ts",
|
||||
])("%s só pode ser importado no servidor", (file) => {
|
||||
const source = readFileSync(join(root, file), "utf8");
|
||||
expect(source).toMatch(/^import "server-only";$/m);
|
||||
});
|
||||
|
||||
it("declara server-only como dependência", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
expect(manifest.dependencies["server-only"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("não deixa nenhum componente de cliente importar um módulo só de servidor", () => {
|
||||
const serverOnly =
|
||||
/from "(?:@\/lib\/og-card|@\/lib\/portfolio-data\.server|(?:\.{1,2}\/)+(?:[\w-]+\/)*case-media)"/;
|
||||
const clientFiles = [
|
||||
"app/cases/_components/CasePlayer.tsx",
|
||||
"app/curriculo/CvDownloadLink.tsx",
|
||||
];
|
||||
for (const file of clientFiles) {
|
||||
const source = readFileSync(join(root, file), "utf8");
|
||||
expect(source).toMatch(/^"use client";/);
|
||||
expect(source).not.toMatch(serverOnly);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import portfolioData from "@/public/portfolio-data.json";
|
||||
import {
|
||||
caseStudies,
|
||||
clientCases,
|
||||
@@ -77,7 +78,7 @@ it("leaves the commercial offer of every service empty for the owner to fill in"
|
||||
|
||||
it("mentions the region and a benefit in the title and description of every service", () => {
|
||||
for (const service of servicePages) {
|
||||
expect(`${service.seoTitle} ${service.metaDescription}`).toMatch(
|
||||
expect(`${service.metaTitle} ${service.metaDescription}`).toMatch(
|
||||
/Garuva|Joinville|Santa Catarina/,
|
||||
);
|
||||
expect(service.metaDescription).toMatch(/remoto|Brasil/);
|
||||
@@ -148,6 +149,89 @@ it("resolves every declared related case ID", () => {
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("only cites project ids that exist in public/portfolio-data.json", () => {
|
||||
const projectIds = new Set(portfolioData.projects.map(({ id }) => id));
|
||||
const cited = [
|
||||
...caseStudies.map(({ id }) => id),
|
||||
...servicePages.flatMap(({ relatedCaseIds }) => relatedCaseIds),
|
||||
...servicePages.flatMap(({ caseRelevance }) => Object.keys(caseRelevance)),
|
||||
];
|
||||
|
||||
expect(cited.length).toBeGreaterThan(0);
|
||||
expect(cited.filter((id) => !projectIds.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes one sentence on why each related case matters to its service", () => {
|
||||
for (const service of servicePages) {
|
||||
expect(Object.keys(service.caseRelevance).sort()).toEqual(
|
||||
[...service.relatedCaseIds].sort(),
|
||||
);
|
||||
for (const sentence of Object.values(service.caseRelevance)) {
|
||||
expect(sentence.length).toBeGreaterThan(30);
|
||||
expect(sentence).toMatch(/[.]$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
|
||||
const apolo = portfolioData.experiences.find(
|
||||
({ id }) => id === "apolo-energia",
|
||||
);
|
||||
expect(apolo?.period).toMatch(/2022/);
|
||||
|
||||
const withNote = servicePages
|
||||
.filter(({ experienceNote }) => experienceNote)
|
||||
.map(({ slug }) => slug);
|
||||
expect(withNote).toEqual([
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]);
|
||||
for (const slug of withNote) {
|
||||
const note = getService(slug).experienceNote ?? "";
|
||||
expect(note).toMatch(/Apolo Energia/);
|
||||
expect(note).toMatch(/desde 2022/i);
|
||||
expect(note).toMatch(/sistemas internos/i);
|
||||
expect(note).toMatch(/aplicativos/i);
|
||||
expect(note).toMatch(/APIs/);
|
||||
// sem link e sem detalhe de operação do cliente
|
||||
expect(note).not.toMatch(/https?:|www\.|\.com/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
|
||||
for (const caseStudy of clientCases) {
|
||||
expect(caseStudy.problem.length).toBeGreaterThan(200);
|
||||
expect(caseStudy.problem).not.toMatch(/["“”]/);
|
||||
expect(caseStudy.problem).not.toMatch(/\d+\s?%|R\$\s?\d/);
|
||||
}
|
||||
expect(
|
||||
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
|
||||
).toMatch(/tamanho/);
|
||||
expect(
|
||||
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
|
||||
).toMatch(/WhatsApp/);
|
||||
});
|
||||
|
||||
it("leaves the optional facts of every case empty until there is real data", () => {
|
||||
for (const caseStudy of caseStudies) {
|
||||
expect(caseStudy.facts ?? []).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes project subtitles without technical jargon", () => {
|
||||
const jargon =
|
||||
/Next\.js|React|MongoDB|MinGW|\/GCC|tkinter|\bSSE\b|Three\.js|em código|r\/artificial/i;
|
||||
for (const caseStudy of caseStudies) {
|
||||
expect(caseStudy.subtitle).not.toMatch(jargon);
|
||||
}
|
||||
for (const project of portfolioData.projects) {
|
||||
expect(project.subtitle).not.toMatch(jargon);
|
||||
}
|
||||
expect(
|
||||
portfolioData.projects.find(({ id }) => id === "sc-plus")?.subtitle,
|
||||
).toBe("Site institucional com áreas, projetos e contato");
|
||||
});
|
||||
|
||||
it("does not publish the unavailable Twins Lab demo", () => {
|
||||
expect(
|
||||
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
|
||||
+102
-27
@@ -8,7 +8,9 @@ export interface ServicePageContent {
|
||||
slug: string;
|
||||
navLabel: string;
|
||||
eyebrow: string;
|
||||
seoTitle: string;
|
||||
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
||||
metaTitle: string;
|
||||
/** Descrição para busca, com até 155 caracteres. */
|
||||
metaDescription: string;
|
||||
headline: string;
|
||||
intro: string;
|
||||
@@ -22,6 +24,10 @@ export interface ServicePageContent {
|
||||
ctaLabel: string;
|
||||
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
|
||||
relatedCaseIds: string[];
|
||||
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
|
||||
caseRelevance: Record<string, string>;
|
||||
/** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */
|
||||
experienceNote?: string;
|
||||
/** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */
|
||||
startingPrice?: string;
|
||||
typicalTimeline?: string;
|
||||
@@ -35,9 +41,13 @@ export interface CaseStudyContent {
|
||||
title: string;
|
||||
/** Tipo do projeto, em poucas palavras. */
|
||||
subtitle: string;
|
||||
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
||||
metaTitle: string;
|
||||
/** Descrição para busca, com até 155 caracteres. */
|
||||
metaDescription: string;
|
||||
/** Título da página, em linguagem de negócio. */
|
||||
headline: string;
|
||||
/** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */
|
||||
/** Resumo de uma ou duas frases (cards e abertura da página). */
|
||||
summary: string;
|
||||
/** O que muda para quem usa, em uma frase. */
|
||||
impact: string;
|
||||
@@ -45,11 +55,13 @@ export interface CaseStudyContent {
|
||||
context: string;
|
||||
/** O problema que o projeto resolve. */
|
||||
problem: string;
|
||||
/** Fatos verificáveis do projeto, como "No ar desde ...". Só aparece na página quando há algum; sem dado real, fica de fora. */
|
||||
facts?: string[];
|
||||
/** O que foi feito, escrito como benefício. */
|
||||
deliveries: string[];
|
||||
/** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */
|
||||
result?: string[];
|
||||
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos no fim da página). */
|
||||
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
|
||||
stack: string[];
|
||||
decisions: string[];
|
||||
demoUrl?: string | null;
|
||||
@@ -62,9 +74,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "criacao-de-sites",
|
||||
navLabel: "Sites profissionais",
|
||||
eyebrow: "Presença digital",
|
||||
seoTitle: "Criação de Sites para Empresas em Garuva e Joinville",
|
||||
metaTitle: "Criação de sites para empresas em Garuva",
|
||||
metaDescription:
|
||||
"Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.",
|
||||
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
headline: "Um site que explica seu negócio.",
|
||||
answer:
|
||||
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
|
||||
@@ -99,6 +111,16 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
|
||||
ctaLabel: "Conversar sobre meu site",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.",
|
||||
"lavacao-047":
|
||||
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
|
||||
"sc-plus":
|
||||
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
|
||||
"as-servicos":
|
||||
"Um site institucional que conta duas frentes da empresa em uma só história, com cena 3D na rolagem.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Quanto custa e o que preciso ter pronto?",
|
||||
@@ -121,9 +143,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Sistemas web e aplicativos mobile",
|
||||
eyebrow: "Engenharia de software",
|
||||
seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina",
|
||||
metaTitle: "Sistemas web e aplicativos sob medida",
|
||||
metaDescription:
|
||||
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.",
|
||||
headline: "Sistemas e apps para o seu trabalho.",
|
||||
answer:
|
||||
"Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
|
||||
@@ -158,6 +180,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||
ctaLabel: "Conversar sobre meu sistema ou app",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
|
||||
"lavacao-047":
|
||||
"Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Preciso saber qual tecnologia usar?",
|
||||
@@ -180,9 +208,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "consultoria-nextjs",
|
||||
navLabel: "Melhorias em sites e sistemas Next.js",
|
||||
eyebrow: "Consultoria Next.js",
|
||||
seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema",
|
||||
metaTitle: "Consultoria Next.js para site ou sistema",
|
||||
metaDescription:
|
||||
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.",
|
||||
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.",
|
||||
headline: "Seu projeto Next.js pode funcionar melhor.",
|
||||
answer:
|
||||
"Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.",
|
||||
@@ -217,6 +245,14 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
|
||||
ctaLabel: "Conversar sobre meu projeto Next.js",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.",
|
||||
"lavacao-047":
|
||||
"Um projeto em Next.js 16 em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
|
||||
"sc-plus":
|
||||
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Não sei se meu site usa Next.js. Posso conversar?",
|
||||
@@ -239,9 +275,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "tauri-rust",
|
||||
navLabel: "Aplicativos desktop para Windows e Linux",
|
||||
eyebrow: "Aplicativos desktop",
|
||||
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
|
||||
metaTitle: "Aplicativos desktop para Windows e Linux",
|
||||
metaDescription:
|
||||
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.",
|
||||
headline: "Aplicativos para Windows e Linux.",
|
||||
answer:
|
||||
"Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.",
|
||||
@@ -281,6 +317,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
ctaLabel: "Conversar sobre meu aplicativo desktop",
|
||||
relatedCaseIds: [],
|
||||
caseRelevance: {},
|
||||
faq: [
|
||||
{
|
||||
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
|
||||
@@ -303,9 +340,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "integracao-hardware-software-iot",
|
||||
navLabel: "Integração de equipamentos e sistemas",
|
||||
eyebrow: "Hardware, software e IoT",
|
||||
seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina",
|
||||
metaTitle: "Integração de equipamentos e sistemas (IoT)",
|
||||
metaDescription:
|
||||
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.",
|
||||
headline: "Dados dos equipamentos no seu sistema.",
|
||||
answer:
|
||||
"Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.",
|
||||
@@ -340,6 +377,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
|
||||
ctaLabel: "Avaliar minha integração",
|
||||
relatedCaseIds: ["twins-lab"],
|
||||
caseRelevance: {
|
||||
"twins-lab":
|
||||
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
|
||||
},
|
||||
experienceNote:
|
||||
"Desde 2022 trabalho com isso na Apolo Energia: sistemas internos, aplicativos, APIs e integração de dispositivos IoT para a troca de dados entre serviços e parceiros. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
|
||||
faq: [
|
||||
{
|
||||
question: "Qualquer equipamento pode ser conectado?",
|
||||
@@ -362,10 +405,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "arquitetura-de-software",
|
||||
navLabel: "Diagnóstico e arquitetura de software",
|
||||
eyebrow: "Diagnóstico e decisão",
|
||||
seoTitle:
|
||||
"Diagnóstico e Arquitetura de Software para Decidir com Segurança",
|
||||
metaTitle: "Diagnóstico e arquitetura de software",
|
||||
metaDescription:
|
||||
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.",
|
||||
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.",
|
||||
headline: "Entenda o problema antes de mudar tudo.",
|
||||
answer:
|
||||
"Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.",
|
||||
@@ -406,6 +448,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
ctaLabel: "Conversar sobre um diagnóstico",
|
||||
relatedCaseIds: ["sev-modaintima"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.",
|
||||
},
|
||||
experienceNote:
|
||||
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
|
||||
faq: [
|
||||
{
|
||||
question: "Preciso ter uma equipe técnica para contratar?",
|
||||
@@ -431,6 +479,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "sev-modaintima",
|
||||
title: "SEV Moda Íntima",
|
||||
subtitle: "Catálogo digital e loja virtual",
|
||||
metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp",
|
||||
metaDescription:
|
||||
"Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.",
|
||||
headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp",
|
||||
summary:
|
||||
"Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.",
|
||||
@@ -439,7 +490,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.",
|
||||
problem:
|
||||
"Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.",
|
||||
"A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.",
|
||||
deliveries: [
|
||||
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
|
||||
"Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.",
|
||||
@@ -468,6 +519,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "lavacao-047",
|
||||
title: "Lavação 047",
|
||||
subtitle: "Site e painel para lavação automotiva",
|
||||
metaTitle: "Lavação 047: site com preços e WhatsApp",
|
||||
metaDescription:
|
||||
"Site da Lavação 047, em Garuva-SC: preço pelo porte do veículo, pedido pelo WhatsApp e painel para o dono editar tudo sozinho.",
|
||||
headline:
|
||||
"Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva",
|
||||
summary:
|
||||
@@ -477,7 +531,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.",
|
||||
problem:
|
||||
"Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.",
|
||||
"O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
|
||||
deliveries: [
|
||||
"Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.",
|
||||
"Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.",
|
||||
@@ -508,6 +562,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "nutriscan",
|
||||
title: "NutriScan",
|
||||
subtitle: "Consulta de produtos por nome e código de barras",
|
||||
metaTitle: "NutriScan: consulta de alimentos",
|
||||
metaDescription:
|
||||
"Consulta de alimentos por nome ou código de barras, com cache próprio e a base pública Open Food Facts. Projeto próprio, no ar para teste.",
|
||||
headline: "Consulta de alimentos por nome ou código de barras",
|
||||
summary:
|
||||
"Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.",
|
||||
@@ -534,7 +591,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "twins-lab",
|
||||
title: "Twins Lab",
|
||||
subtitle: "Protótipo de laboratório de gêmeo digital industrial",
|
||||
subtitle: "Painel ao vivo de um reator simulado",
|
||||
metaTitle: "Twins Lab: painel de reator simulado",
|
||||
metaDescription:
|
||||
"Protótipo de painel ao vivo que mostra a simulação de um reator industrial, com acesso por login e histórico guardado. Projeto próprio.",
|
||||
headline:
|
||||
"Painel ao vivo para acompanhar a simulação de um reator industrial",
|
||||
summary:
|
||||
@@ -561,7 +621,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
title: "Reddit Posts Explorer",
|
||||
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
subtitle: "Posts de um fórum de IA, filtrados por período",
|
||||
metaTitle: "Reddit Posts Explorer: coleta e filtros",
|
||||
metaDescription:
|
||||
"Coleta os posts em alta do r/artificial sem duplicar e permite filtrar por período e ordenar por votos ou comentários. Projeto próprio.",
|
||||
headline:
|
||||
"Posts de um fórum de inteligência artificial, coletados e filtrados por período",
|
||||
summary:
|
||||
@@ -588,7 +651,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "sc-plus",
|
||||
title: "SC Plus Aquicultura",
|
||||
subtitle: "Página institucional",
|
||||
subtitle: "Site institucional com áreas, projetos e contato",
|
||||
metaTitle: "SC Plus Aquicultura: página institucional",
|
||||
metaDescription:
|
||||
"Página institucional da SC Plus Aquicultura, com áreas de atuação, projetos, apoiadores e contato, preparada para os buscadores.",
|
||||
headline:
|
||||
"Página institucional para a SC Plus Aquicultura ser encontrada na internet",
|
||||
summary:
|
||||
@@ -596,9 +662,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
impact:
|
||||
"A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.",
|
||||
context:
|
||||
"A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
|
||||
"A SC Plus Aquicultura é uma instituição de Garuva, em Santa Catarina, que atua com educação, ciência e tecnologia sustentável. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
|
||||
problem:
|
||||
"Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.",
|
||||
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
|
||||
deliveries: [
|
||||
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
|
||||
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
|
||||
@@ -616,7 +682,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "gcc-installer",
|
||||
title: "MinGW GCC Installer",
|
||||
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
subtitle: "Instalador do compilador GCC para Windows",
|
||||
metaTitle: "MinGW GCC Installer: instalador no Windows",
|
||||
metaDescription:
|
||||
"Aplicativo para Windows que lista as versões do MinGW-w64, instala o compilador GCC e ajusta o PATH, tudo em uma janela. Projeto próprio.",
|
||||
headline: "Instalador do compilador GCC para Windows em uma única janela",
|
||||
summary:
|
||||
"Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.",
|
||||
@@ -644,6 +713,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "as-servicos",
|
||||
title: "AS Serviços",
|
||||
subtitle: "Site institucional com cena 3D",
|
||||
metaTitle: "AS Serviços: site com cena 3D",
|
||||
metaDescription:
|
||||
"Site institucional da AS Serviços com uma cena 3D que acompanha a rolagem e conta a engenharia física e o software numa só história.",
|
||||
headline: "Site institucional com uma cena 3D que acompanha a rolagem",
|
||||
summary:
|
||||
"Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.",
|
||||
@@ -652,7 +724,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.",
|
||||
problem:
|
||||
"Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?",
|
||||
"A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.",
|
||||
deliveries: [
|
||||
"Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.",
|
||||
"As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.",
|
||||
@@ -670,10 +742,13 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const clientProjectIds = new Set(
|
||||
portfolio.projects.filter(isClientProject).map(({ id }) => id),
|
||||
);
|
||||
|
||||
/** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */
|
||||
export function isClientCase(caseStudy: { id: string }) {
|
||||
const project = portfolio.projects.find(({ id }) => id === caseStudy.id);
|
||||
return project ? isClientProject(project) : false;
|
||||
return clientProjectIds.has(caseStudy.id);
|
||||
}
|
||||
|
||||
export const clientCases = caseStudies.filter(isClientCase);
|
||||
|
||||
Reference in new issue
Block a user