Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/build-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: frontend-ci

on:
pull_request:
branches: [develop, main]
branches: [develop, main, codex/poc]
types: [opened, synchronize, reopened]

permissions:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Standardy front-endu GI są w [CLAUDE.md](CLAUDE.md).

- **Nowy moduł Console:** dopisz wpis do `CONSOLE_MODULES` w `src/constants/console.ts`, dodaj widok w `src/components/<domena>/` i cienki plik w `src/pages/`.
- **Nowy szablon grafiki (Marketing):** utwórz folder `src/components/marketing/ExportLab/templates/<Nazwa>Template/` z komponentem (HTML + Tailwind, rysowany w pikselach formatu) oraz plikiem `.constants.ts` (nazwa, pola, limity, obsługa zdjęcia). Dopisz go do `GRAPHIC_TEMPLATES` w `ExportLab.constants.ts`. Pojemniki, które nie mogą się przepełnić, oznacz atrybutem `data-fit`; eksport zablokuje wtedy przyciętą treść.
- **Nowy grant dla szablonu Standard:** dodaj jego dwa obrazy do `public/grants/` i wpis w `src/components/marketing/ExportLab/templates/StandardTemplate/fundingOptions.ts`. Wybór w formularzu i belka w eksporcie korzystają z tego samego katalogu; wartość `none` oznacza brak belki.

PNG powstaje przez rasteryzację wyrenderowanego szablonu (`modern-screenshot`); podgląd jest dokładnie pobieranym plikiem.

Expand Down
62 changes: 62 additions & 0 deletions e2e/marketing/marketing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,68 @@ const waitForPreviews = (page: Page) =>
expect(page.getByRole("link", { name: /^Pobierz/ })).toHaveCount(4);

test.describe("Feature: Marketing graphics export", () => {
test("Scenario: PROO bar is optional and exported at the top of every format", async ({
page,
}, info) => {
await page.goto("./marketing");
await waitForPreviews(page);
const noFunding = page.getByRole("radio", { name: "Bez belki" });
const proo = page.getByRole("radio", { name: "PROO" });
await expect(noFunding).toBeChecked();
const square = page.getByRole("img", { name: /^Post kwadratowy:/ });
const before = await square.getAttribute("src");

await choose(page, "PROO");
await expect(square).not.toHaveAttribute("src", before!);
await waitForPreviews(page);

for (const format of FORMATS) {
const image = page.getByRole("img", {
name: new RegExp(`^${format.name}:`),
});
const pixels = await image.evaluate((img: HTMLImageElement) => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(img, 0, 0);
const top = context.getImageData(0, 0, canvas.width, 160).data;
let leftWhite = 0;
let rightWhite = 0;
for (let y = 32; y < 155; y++) {
for (let x = 64; x < canvas.width - 64; x++) {
const offset = (y * canvas.width + x) * 4;
if (
top[offset] > 220 &&
top[offset + 1] > 220 &&
top[offset + 2] > 220
) {
if (x < canvas.width / 2) leftWhite++;
else rightWhite++;
}
}
}
return {
blackCorner: Array.from(top.slice(0, 4)),
leftWhite,
rightWhite,
};
});
expect(pixels.blackCorner).toEqual([0, 0, 0, 255]);
expect(pixels.leftWhite).toBeGreaterThan(100);
expect(pixels.rightWhite).toBeGreaterThan(100);
}

await page.screenshot({
path: info.outputPath("proo-selected.png"),
fullPage: true,
});
const withFunding = await square.getAttribute("src");
await choose(page, "Bez belki");
await expect(square).not.toHaveAttribute("src", withFunding!);
await waitForPreviews(page);
});

test("Scenario: exporting every format of the default template", async ({
page,
}, info) => {
Expand Down
Binary file added public/grants/proo-left.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/grants/proo-right.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ describe("<ContentForm />", () => {
expect(
within(layout).getByRole("radiogroup", { name: "Położenie tekstu" }),
).toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Finansowanie" })).getByRole(
"radio",
{ name: "Bez belki" },
),
).toBeChecked();
expect(
within(screen.getByRole("region", { name: "Tło" })).getByLabelText(
"Dodaj zdjęcie",
Expand Down Expand Up @@ -119,9 +125,11 @@ describe("<ContentForm />", () => {
});
fireEvent.click(screen.getByRole("radio", { name: "Tekst u góry" }));
fireEvent.click(screen.getByRole("radio", { name: /Mały \(32 px\)/ }));
fireEvent.click(screen.getByRole("radio", { name: "PROO" }));
expect(onChange).toHaveBeenCalledWith("title", "Nowy");
expect(onChange).toHaveBeenCalledWith("position", "top");
expect(onChange).toHaveBeenCalledWith("titleSize", "32");
expect(onChange).toHaveBeenCalledWith("funding", "proo");
});
});

Expand Down
18 changes: 18 additions & 0 deletions src/components/marketing/ExportLab/ContentForm/ContentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import EditorPanel from "../EditorPanel/EditorPanel";
import type {
ChoiceTemplateField,
FieldValues,
FundingTemplateField,
GraphicTemplate,
PhotoControls,
PreviewError,
} from "../ExportLab.types";
import { getFieldError } from "../utils/getFieldError";
import ChoiceField from "./ChoiceField/ChoiceField";
import FundingPicker from "./FundingPicker/FundingPicker";
import PhotoField from "./PhotoField/PhotoField";
import TextField from "./TextField/TextField";

Expand All @@ -26,6 +28,9 @@ const ContentForm = ({ template, values, error, onChange, photo }: Props) => {
(field): field is ChoiceTemplateField => field.kind === "choice",
);
const layoutChoices = choices.filter((field) => !field.attachTo);
const fundingFields = template.fields.filter(
(field): field is FundingTemplateField => field.kind === "funding",
);
const renderChoice = (field: ChoiceTemplateField) => (
<ChoiceField
key={field.id}
Expand Down Expand Up @@ -75,6 +80,19 @@ const ContentForm = ({ template, values, error, onChange, photo }: Props) => {
</div>
</EditorPanel>
)}
{fundingFields.map((field) => (
<EditorPanel
key={field.id}
titleId={`${field.id}-title`}
title={field.label}
>
<FundingPicker
field={field}
value={values[field.id] ?? field.defaultValue}
onChange={(value) => onChange(field.id, value)}
/>
</EditorPanel>
))}
{template.supportsPhoto && (
<EditorPanel titleId="background-title" title="Tło">
<PhotoField photo={photo} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { FundingTemplateField } from "../../ExportLab.types";

interface Props {
field: FundingTemplateField;
value: string;
onChange: (value: string) => void;
}

/** Same card-and-radio interaction as the template list. */
const FundingPicker = ({ field, value, onChange }: Props) => (
<div className="grid gap-2" role="radiogroup" aria-label={field.label}>
{field.options.map((option) => (
<label
key={option.value}
className="flex cursor-pointer items-center gap-4 rounded-lg border border-app-border p-2 hover:bg-app-hover has-checked:border-app-accent has-checked:bg-app-accent/10 has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-app-focus"
>
<input
type="radio"
name={field.id}
value={option.value}
checked={option.value === value}
onChange={() => onChange(option.value)}
aria-label={option.label}
className="sr-only"
/>
<span
aria-hidden="true"
className="flex size-[72px] shrink-0 items-center justify-center overflow-hidden rounded-md border border-app-border bg-black"
>
{option.leftImage && option.rightImage ? (
<span className="flex w-full gap-0.5 px-1">
<img src={option.leftImage} alt="" className="w-1/2" />
<img src={option.rightImage} alt="" className="w-1/2" />
</span>
) : (
<span className="text-2xl text-app-subtle">—</span>
)}
</span>
<strong className="text-base">{option.label}</strong>
</label>
))}
</div>
);

export default FundingPicker;
1 change: 0 additions & 1 deletion src/components/marketing/ExportLab/ExportLab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { InfoMessage } from "@gi-org-pl/athena";
import { useMemo, useState } from "react";
import PageHeading from "../../shared/PageHeading/PageHeading";
import ContentForm from "./ContentForm/ContentForm";
Expand Down
17 changes: 16 additions & 1 deletion src/components/marketing/ExportLab/ExportLab.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,22 @@ export interface ChoiceTemplateField extends BaseTemplateField {
attachTo?: string;
}

export type TemplateField = TextTemplateField | ChoiceTemplateField;
export interface FundingOption {
value: string;
label: string;
leftImage?: string;
rightImage?: string;
}

export interface FundingTemplateField extends BaseTemplateField {
kind: "funding";
options: readonly FundingOption[];
}

export type TemplateField =
| TextTemplateField
| ChoiceTemplateField
| FundingTemplateField;

export type FieldValues = Record<string, string>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { getFundingGrant } from "../fundingOptions";

interface Props {
grant: NonNullable<ReturnType<typeof getFundingGrant>>;
}

/** Both supplied PNGs have the same 3603×459 ratio and sit on one black strip. */
const FundingBanner = ({ grant }: Props) => (
<div
data-funding-banner={grant.value}
className="relative z-10 flex w-full shrink-0 items-center gap-8 bg-black px-16 py-8"
>
<img
src={grant.leftImage}
alt=""
width="3603"
height="459"
className="block w-0 min-w-0 flex-1"
/>
<img
src={grant.rightImage}
alt=""
width="3603"
height="459"
className="block w-0 min-w-0 flex-1"
/>
</div>
);

export default FundingBanner;
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import footerText from "../../../../../../assets/icons/footer-text.svg";
import type { GraphicContent } from "../../../ExportLab.types";
import { stripHighlights } from "../../../utils/tokenizeHighlights";
import HighlightedText from "../../HighlightedText/HighlightedText";
import FundingBanner from "../FundingBanner/FundingBanner";
import type { getFundingGrant } from "../fundingOptions";
import { getStandardBackground } from "../utils/getStandardBackground";

interface Props extends GraphicContent {
width: number;
height: number;
className?: string;
funding?: NonNullable<ReturnType<typeof getFundingGrant>>;
}

// Safe alignment falls back to the top when text overflows, so the fit check sees it.
Expand All @@ -28,7 +31,14 @@ const TEXT_ALIGN_CLASS_NAME: Record<string, string> = {
const hasText = (value?: string) => !!value && !!stripHighlights(value).trim();

/** The standard GI composition: background or photo, text and the footer. */
const StandardCard = ({ values, photo, width, height, className }: Props) => (
const StandardCard = ({
values,
photo,
width,
height,
className,
funding,
}: Props) => (
<div
className={twMerge(
"relative flex flex-col overflow-hidden bg-app-bg text-app-text",
Expand All @@ -53,6 +63,7 @@ const StandardCard = ({ values, photo, width, height, className }: Props) => (
<div className="absolute inset-0 bg-linear-to-b from-black/50 to-black" />
</>
)}
{funding && <FundingBanner grant={funding} />}
<div
data-fit
className={twMerge(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
TEXT_POSITION_OPTIONS,
textSizeOptions,
} from "../field-options";
import { FUNDING_OPTIONS } from "./fundingOptions";
import StandardTemplate from "./StandardTemplate";

export const STANDARD_TEMPLATE: GraphicTemplate = {
Expand Down Expand Up @@ -58,6 +59,13 @@ export const STANDARD_TEMPLATE: GraphicTemplate = {
options: TEXT_ALIGN_OPTIONS,
defaultValue: "left",
},
{
kind: "funding",
id: "funding",
label: "Finansowanie",
options: FUNDING_OPTIONS,
defaultValue: "none",
},
],
Component: StandardTemplate,
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,54 @@ describe("<StandardTemplate />", () => {
});
});

describe("when PROO funding is selected", () => {
it("puts both supplied images in a black bar above the text on a square card", () => {
const { container } = render(
<StandardTemplate
values={{ ...values, funding: "proo" }}
photo={null}
format={square}
/>,
);
const banner = container.querySelector('[data-funding-banner="proo"]');
expect(banner).toHaveClass("bg-black", "px-16", "py-8");
expect(banner?.querySelectorAll("img")).toHaveLength(2);
expect(banner?.nextElementSibling).toHaveAttribute("data-fit");
expect(container.firstElementChild).toHaveStyle({
width: "1080px",
height: "1080px",
});
});

it("puts the bar at the top of the whole story above its framed card", () => {
const { container } = render(
<StandardTemplate
values={{ ...values, funding: "proo" }}
photo={null}
format={GRAPHIC_FORMATS[2]}
/>,
);
expect(container.firstElementChild?.firstElementChild).toHaveAttribute(
"data-funding-banner",
"proo",
);
expect(container.querySelectorAll("[data-funding-banner]")).toHaveLength(
1,
);
});

it("omits the bar when no funding is selected", () => {
const { container } = render(
<StandardTemplate
values={{ ...values, funding: "none" }}
photo={null}
format={square}
/>,
);
expect(container.querySelector("[data-funding-banner]")).toBeNull();
});
});

describe("when given texts, sizes and a highlight", () => {
it("renders them with the highlight in the accent color", () => {
render(<StandardTemplate values={values} photo={null} format={square} />);
Expand Down
Loading
Loading