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
11 changes: 9 additions & 2 deletions e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,16 @@ test("shows the current year in the footer, not the year of the build", async ({
await expect(page.locator("footer")).toContainText("© 2031");
});

test("quotes three repeat clients by name under the numbers, each with a rating", async ({ page }) => {
test("quotes Buddy Punch's co-founder first, with no rating, then three repeat Upwork clients with theirs", async ({ page }) => {
await page.goto("/");
await expect(page.locator("section#about figure")).toHaveCount(3);
const figures = page.locator("section#about figure");
await expect(figures).toHaveCount(4);
await expect(figures.first().locator("blockquote p")).toHaveCount(2);
const first = figures.first().locator("figcaption");
await expect(first).toContainText("Nick Murphy · Co-Founder, Buddy Punch");
await expect(first).toContainText("LinkedIn recommendation");
await expect(first).not.toContainText("Rated");
await expect(first).not.toContainText("★");
for (const name of ["Bruce van Zyl", "Chris Robichaud", "Alex Harper"]) {
const caption = page.locator("section#about figcaption", { hasText: name });
await expect(caption).toContainText("Rated 5 out of 5.");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "naidenko-dev",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
21 changes: 14 additions & 7 deletions src/components/About.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { aboutParagraphs, numbers } from "@/content/about";
import { testimonials } from "@/content/testimonials";
import { quoteParagraphs } from "@/lib/format";

import { Section } from "./Section";

Expand All @@ -24,15 +25,21 @@ export function About() {
{testimonials.items.map(item => (
<li key={item.name}>
<figure className="border-l-2 border-ink-faint/20 pl-5">
<blockquote>
<p>“{item.quote}”</p>
<blockquote className="space-y-3">
{quoteParagraphs(item.quote).map(paragraph => (
<p key={paragraph.slice(0, 32)}>{paragraph}</p>
))}
</blockquote>
<figcaption className="mt-3 text-sm leading-snug">
<span className="text-accent" aria-hidden="true">
{"★".repeat(item.rating)}
</span>
<span className="sr-only">Rated {item.rating} out of 5.</span>
<span className="ml-2 font-medium text-ink-strong">{item.name}</span>
{item.rating ? (
<>
<span className="mr-2 text-accent" aria-hidden="true">
{"★".repeat(item.rating)}
</span>
<span className="sr-only">Rated {item.rating} out of 5.</span>
</>
) : null}
<span className="font-medium text-ink-strong">{item.name}</span>
{"project" in item ? <span className="text-ink-faint"> · {item.project}</span> : null}
<span className="mt-0.5 block text-ink-faint">{item.hired}</span>
</figcaption>
Expand Down
17 changes: 13 additions & 4 deletions src/content/testimonials.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
export interface Testimonial {
/** Paragraphs are separated by a blank line. */
quote: string;
name: string;
project?: string;
hired: string;
/** The client's Upwork rating for that contract. */
rating: number;
/** The client's Upwork rating for that contract; a LinkedIn recommendation has none. */
rating?: number;
}

/**
* Upwork client reviews, verbatim, from the contract pages saved 2026-09-24. Chris Robichaud also
* posted the same words as a LinkedIn recommendation (April 2019).
* Verbatim. Nick Murphy's LinkedIn recommendation of 2026-10-09 (Andrii's screenshot of 2026-10-10
* in the resume repository's `permissions/`), then Upwork client reviews from the contract pages
* saved 2026-09-24. Chris Robichaud also posted the same words as a LinkedIn recommendation (April
* 2019).
*/
export const testimonials = {
heading: "What clients said",
items: [
{
quote: "Andrii is one of the easiest people I’ve worked with. He is reliable, communicates clearly, and raises problems early instead of letting them surprise you later. When something is unclear, his question comes with a proposed answer, which makes every decision faster. I never have to chase him for status or wonder where things stand.\n\nHe has been a main contributor to our mobile app at Buddy Punch, covering new features, hotfixes and releases, and he takes care with all of it. I’d recommend Andrii to any team looking for a strong mobile developer who is genuinely good to work with.",
name: "Nick Murphy",
project: "Co-Founder, Buddy Punch",
hired: "Client through Toptal since 2019 · LinkedIn recommendation"
},
{
quote: "Andrii has been one of our best, most reliable developers on many projects. He works efficiently, communicates effectively, and cares about his quality of work. We will definitely work with him again.",
name: "Bruce van Zyl",
Expand Down
12 changes: 11 additions & 1 deletion src/lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { compactFigure } from "./format";
import { compactFigure, quoteParagraphs } from "./format";

describe("compactFigure", () => {
it("shortens a store figure and keeps its plus sign", () => {
Expand All @@ -12,3 +12,13 @@ describe("compactFigure", () => {
expect(compactFigure("731")).toBe("731");
});
});

describe("quoteParagraphs", () => {
it("opens the quote on the first paragraph and closes it on the last", () => {
expect(quoteParagraphs("One.\n\nTwo.\n\nThree.")).toEqual(["“One.", "Two.", "Three.”"]);
});

it("quotes a single paragraph whole", () => {
expect(quoteParagraphs("Only.")).toEqual(["“Only.”"]);
});
});
8 changes: 8 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@ export function compactFigure(figure: string): string {
const plus = figure.endsWith("+") ? "+" : "";
return compact.format(Number(figure.replace(/[^\d.]/g, ""))) + plus;
}

/** A quote's paragraphs (split on a blank line), with the opening mark on the first and the closing one on the last. */
export function quoteParagraphs(quote: string): string[] {
const paragraphs = quote.split("\n\n");
paragraphs[0] = `“${paragraphs[0]}`;
paragraphs[paragraphs.length - 1] += "”";
return paragraphs;
}
9 changes: 9 additions & 0 deletions src/lib/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ describe("pageMarkdown", () => {
expect(md).toContain("(https://www.toptal.com/developers/resume/andrii-naidenko#qjl3b7)");
});

it("rates only the reviews that came with a rating", () => {
expect(md).toContain("> — Nick Murphy, Co-Founder, Buddy Punch. Client through Toptal since 2019 · LinkedIn recommendation.\n");
expect(md.match(/Rated 5 out of 5\./g)).toHaveLength(3);
});

it("keeps a review's paragraphs inside its quote", () => {
expect(md).toContain("where things stand.\n>\n> He has been a main contributor");
});

it("gives no email address and no form, which an agent sent from Toptal would read", () => {
expect(md).not.toContain("@naidenko.dev");
expect(md).not.toContain("#contact");
Expand Down
4 changes: 2 additions & 2 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export function pageMarkdown(): string {
`## ${testimonials.heading}`,
"",
...testimonials.items.flatMap(item => [
`> ${item.quote}`,
...item.quote.split("\n\n").flatMap((paragraph, i) => (i ? [">", `> ${paragraph}`] : [`> ${paragraph}`])),
">",
`> — ${item.name}${"project" in item ? `, ${item.project}` : ""}. ${item.hired}. Rated ${item.rating} out of 5.`,
`> — ${item.name}${"project" in item ? `, ${item.project}` : ""}. ${item.hired}.${item.rating ? ` Rated ${item.rating} out of 5.` : ""}`,
""
]),
"## Experience",
Expand Down
Loading