Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps
diff options
context:
space:
mode:
authorLeitwolf2026-10-23 13:01:49 +0300
committerGitHub2026-10-23 13:01:49 +0300
commitf22418235a0a87bc05b3bf9079fcdefba002d3b2 (patch)
tree603c9323bbd1e7706f9d0b9dc0953f42b1809727 /apps
parentba89b398976f4429338673afc1b6e961e995089f (diff)
parent85bdf1aba26641bf90da84fd6ce642ac41a6e73e (diff)
downloadprogmaweb-f22418235a0a87bc05b3bf9079fcdefba002d3b2.tar.gz
Merge pull request #46 from Progmasoft/feature/progmasoft-site
Redesign progmasoft.com; add product.progmasoft.com, ides.progmasoft.com/xide and catch3.progmasoft.com
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/catch3/layout.tsx11
-rw-r--r--apps/web/app/catch3/page.tsx106
-rw-r--r--apps/web/app/globals.css8
-rw-r--r--apps/web/app/ides/layout.tsx11
-rw-r--r--apps/web/app/ides/page.tsx53
-rw-r--r--apps/web/app/ides/xide/page.tsx93
-rw-r--r--apps/web/app/layout.tsx4
-rw-r--r--apps/web/app/page.tsx205
-rw-r--r--apps/web/app/products/[slug]/page.tsx89
-rw-r--r--apps/web/app/products/layout.tsx11
-rw-r--r--apps/web/app/products/page.tsx62
-rw-r--r--apps/web/app/site.css532
-rw-r--r--apps/web/components/Footer.tsx8
-rw-r--r--apps/web/components/PreferenceControls.tsx32
-rw-r--r--apps/web/components/SiteHeader.tsx7
-rw-r--r--apps/web/components/site/AreaIcon.tsx49
-rw-r--r--apps/web/lib/home-messages.ts358
-rw-r--r--apps/web/lib/localization.ts307
-rw-r--r--apps/web/lib/product-sites.ts412
-rw-r--r--apps/web/lib/products.ts505
-rw-r--r--apps/web/lib/sites.ts38
-rw-r--r--apps/web/proxy.ts83
-rw-r--r--apps/web/tests/localization.test.mjs3
-rw-r--r--apps/web/tests/sites.test.mjs81
24 files changed, 2616 insertions, 452 deletions
diff --git a/apps/web/app/catch3/layout.tsx b/apps/web/app/catch3/layout.tsx
new file mode 100644
index 0000000..de7e946
--- /dev/null
+++ b/apps/web/app/catch3/layout.tsx
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { ReactNode } from "react";
+import "../site.css";
+
+export default function Catch3Layout({
+ children,
+}: Readonly<{ children: ReactNode }>) {
+ return children;
+}
diff --git a/apps/web/app/catch3/page.tsx b/apps/web/app/catch3/page.tsx
new file mode 100644
index 0000000..eec2772
--- /dev/null
+++ b/apps/web/app/catch3/page.tsx
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { Metadata } from "next";
+import { Footer } from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { getLocale } from "@/lib/locale.server";
+import { products } from "@/lib/products";
+import { getSitesMessages } from "@/lib/product-sites";
+
+export async function generateMetadata(): Promise<Metadata> {
+ const { catch3 } = getSitesMessages(await getLocale());
+ return {
+ title: `Catch3 · ${catch3.tagline}`,
+ description: catch3.description,
+ alternates: { canonical: "https://catch3.progmasoft.com/" },
+ };
+}
+
+// The example of the Catch3 documentation (docs/progmasoft-properties.md).
+const example = `#include <Progmasoft/Catch3.hpp>
+
+using namespace Progmasoft::Catch3;
+
+TEST_CASE( "reversing twice gives the original", "[property]" ) {
+ CATCH3_CHECK_FOR_ALL(
+ Gen::Vectors( Gen::Integers<int>() ),
+ []( const std::vector<int>& values ) {
+ auto copy = values;
+ std::reverse( copy.begin(), copy.end() );
+ std::reverse( copy.begin(), copy.end() );
+ return copy == values;
+ } );
+}`;
+
+/** catch3.progmasoft.com: the site of Catch3. */
+export default async function Catch3Page() {
+ const locale = await getLocale();
+ const { catch3, common } = getSitesMessages(locale);
+ return (
+ <div className="pm-page">
+ <SiteHeader locale={locale} />
+ <main>
+ <section className="pm-hero">
+ <div className="pm-shell pm-hero-inner">
+ <div>
+ <p className="eyebrow">{catch3.eyebrow}</p>
+ <h1>
+ Catch3 <span>{catch3.tagline}</span>
+ </h1>
+ <p className="pm-lede">{catch3.description}</p>
+ <div className="pm-actions">
+ <a className="button" href={products.catch3.source}>
+ {common.source}
+ </a>
+ <a
+ className="button button-secondary"
+ href="https://product.progmasoft.com/catch3"
+ >
+ {common.productPage}
+ </a>
+ </div>
+ <p className="pm-note">{catch3.disclaimer}</p>
+ </div>
+ <div>
+ <p className="eyebrow">{catch3.exampleTitle}</p>
+ <pre className="pm-code pm-code-light" dir="ltr">
+ <code>{example}</code>
+ </pre>
+ </div>
+ </div>
+ </section>
+
+ <section className="pm-section">
+ <div className="pm-shell">
+ <div className="pm-heading">
+ <h2>{catch3.featuresTitle}</h2>
+ </div>
+ <div className="pm-bento">
+ {catch3.features.map(([title, description]) => (
+ <article className="pm-card" key={title}>
+ <h3>{title}</h3>
+ <p>{description}</p>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+
+ <section className="pm-section pm-section-muted">
+ <div className="pm-shell">
+ <div className="pm-heading">
+ <h2>{catch3.roadmapTitle}</h2>
+ </div>
+ <ul className="pm-features pm-roadmap">
+ {catch3.roadmap.map((item) => (
+ <li key={item}>{item}</li>
+ ))}
+ </ul>
+ </div>
+ </section>
+ </main>
+ <Footer locale={locale} />
+ </div>
+ );
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 3a27983..c405f30 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1415,3 +1415,11 @@ a {
direction: ltr;
unicode-bidi: isolate;
}
+
+/* The theme changes in one frame; nothing animates its colours meanwhile. */
+.theme-switching,
+.theme-switching *,
+.theme-switching *::before,
+.theme-switching *::after {
+ transition: none !important;
+}
diff --git a/apps/web/app/ides/layout.tsx b/apps/web/app/ides/layout.tsx
new file mode 100644
index 0000000..8b1c6b7
--- /dev/null
+++ b/apps/web/app/ides/layout.tsx
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { ReactNode } from "react";
+import "../site.css";
+
+export default function IdesLayout({
+ children,
+}: Readonly<{ children: ReactNode }>) {
+ return children;
+}
diff --git a/apps/web/app/ides/page.tsx b/apps/web/app/ides/page.tsx
new file mode 100644
index 0000000..3a832a8
--- /dev/null
+++ b/apps/web/app/ides/page.tsx
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { Metadata } from "next";
+import { Footer } from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { getLocale } from "@/lib/locale.server";
+import { forwardArrow } from "@/lib/localization";
+import { getCatalogMessages, products } from "@/lib/products";
+import { getSitesMessages } from "@/lib/product-sites";
+
+export async function generateMetadata(): Promise<Metadata> {
+ const { ides } = getSitesMessages(await getLocale());
+ return {
+ title: ides.title,
+ description: ides.description,
+ alternates: { canonical: "https://ides.progmasoft.com/" },
+ };
+}
+
+/** ides.progmasoft.com: the development environments of Progmasoft. */
+export default async function IdesPage() {
+ const locale = await getLocale();
+ const { ides } = getSitesMessages(locale);
+ const xide = getCatalogMessages(locale).products.xide;
+ return (
+ <div className="pm-page">
+ <SiteHeader locale={locale} />
+ <main>
+ <section className="pm-hero">
+ <div className="pm-shell pm-product-hero">
+ <h1>{ides.title}</h1>
+ <p className="pm-lede">{ides.description}</p>
+ </div>
+ </section>
+ <section className="pm-section">
+ <div className="pm-shell pm-product-grid">
+ <a className="pm-card pm-product-card" href="/xide">
+ <span className={`pm-accent pm-accent-${products.xide.accent}`} />
+ <span className="pm-tag">{xide.category}</span>
+ <h3>{products.xide.name}</h3>
+ <p>{xide.tagline}</p>
+ <span className="pm-more">
+ {ides.open} {forwardArrow(locale)}
+ </span>
+ </a>
+ </div>
+ </section>
+ </main>
+ <Footer locale={locale} />
+ </div>
+ );
+}
diff --git a/apps/web/app/ides/xide/page.tsx b/apps/web/app/ides/xide/page.tsx
new file mode 100644
index 0000000..86022e0
--- /dev/null
+++ b/apps/web/app/ides/xide/page.tsx
@@ -0,0 +1,93 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { Metadata } from "next";
+import { Footer } from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { getLocale } from "@/lib/locale.server";
+import { products } from "@/lib/products";
+import { getSitesMessages } from "@/lib/product-sites";
+
+export async function generateMetadata(): Promise<Metadata> {
+ const { xide } = getSitesMessages(await getLocale());
+ return {
+ title: `Xide · ${xide.tagline}`,
+ description: xide.description,
+ alternates: { canonical: "https://ides.progmasoft.com/xide" },
+ };
+}
+
+/** ides.progmasoft.com/xide: the site of Xide. */
+export default async function XidePage() {
+ const locale = await getLocale();
+ const { xide, common } = getSitesMessages(locale);
+ return (
+ <div className="pm-page">
+ <SiteHeader locale={locale} />
+ <main>
+ <section className="pm-hero">
+ <div className="pm-shell pm-hero-inner">
+ <div>
+ <p className="eyebrow">{xide.eyebrow}</p>
+ <h1>
+ Xide <span>{xide.tagline}</span>
+ </h1>
+ <p className="pm-lede">{xide.description}</p>
+ <div className="pm-actions">
+ <a className="button" href={products.xide.source}>
+ {common.source}
+ </a>
+ <a
+ className="button button-secondary"
+ href="https://product.progmasoft.com/xide"
+ >
+ {common.productPage}
+ </a>
+ </div>
+ </div>
+ <aside className="pm-aside">
+ <span className="pm-tag">{common.status}</span>
+ <p>{xide.status}</p>
+ </aside>
+ </div>
+ </section>
+
+ <section className="pm-section">
+ <div className="pm-shell">
+ <div className="pm-heading">
+ <h2>{xide.layoutTitle}</h2>
+ </div>
+ <div className="pm-bento">
+ {xide.layout.map(([title, description]) => (
+ <article className="pm-card" key={title}>
+ <h3>{title}</h3>
+ <p>{description}</p>
+ </article>
+ ))}
+ </div>
+ </div>
+ </section>
+
+ <section className="pm-section pm-section-muted">
+ <div className="pm-shell pm-detail">
+ <div className="pm-heading">
+ <h2>{xide.languagesTitle}</h2>
+ <p>{xide.languagesDescription}</p>
+ </div>
+ <div>
+ <div className="pm-heading">
+ <h2>{xide.architectureTitle}</h2>
+ </div>
+ <ul className="pm-features">
+ {xide.architecture.map((item) => (
+ <li key={item}>{item}</li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ </section>
+ </main>
+ <Footer locale={locale} />
+ </div>
+ );
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index de04763..db5826b 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -46,7 +46,9 @@ export default async function RootLayout({
<html
lang={locale}
data-text-direction={textDirection(locale)}
- data-theme="light"
+ // data-theme belongs to the bootstrap script and the theme control only.
+ // Rendered here, React would set it back to light whenever the server
+ // re-renders the layout (a language switch), and the page would flash.
suppressHydrationWarning
>
<head>
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index ade7da8..ca8672e 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -2,11 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
import type { Metadata } from "next";
-import { Fragment } from "react";
import { Footer } from "@/components/Footer";
import { SiteHeader } from "@/components/SiteHeader";
+import { AreaIcon, areaKinds } from "@/components/site/AreaIcon";
+import { getHomeMessages } from "@/lib/home-messages";
import { getLocale } from "@/lib/locale.server";
import { forwardArrow, getMessages } from "@/lib/localization";
+import "./site.css";
export async function generateMetadata(): Promise<Metadata> {
const { metadata } = getMessages(await getLocale());
@@ -18,154 +20,135 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
-const productLinks = [
- {
- href: "https://tala.run/",
- accent: "violet",
- },
- {
- href: "https://viget.progmasoft.com/",
- accent: "blue",
- },
- {
- href: "https://github.com/Progmasoft",
- accent: "green",
- },
-] as const;
-
+/**
+ * progmasoft.com. Progmasoft is presented as a pool of technology areas; the
+ * products have their own catalog on product.progmasoft.com.
+ */
export default async function Homepage() {
const locale = await getLocale();
- const { home } = getMessages(locale);
+ const { hero, pool, principles, developers } = getHomeMessages(locale);
+ const arrow = forwardArrow(locale);
return (
- <>
+ <div className="pm-page">
<SiteHeader locale={locale} />
<main>
- <section className="hero organization-hero">
- <div className="shell hero-grid">
- <div className="hero-copy">
- <p className="eyebrow">{home.heroEyebrow}</p>
+ <section className="pm-hero">
+ <div className="pm-shell pm-hero-inner">
+ <div>
+ <p className="eyebrow">{hero.eyebrow}</p>
<h1>
- {home.heroTitleStart} <span>{home.heroTitleAccent}</span>
+ {hero.titleStart} <span>{hero.titleAccent}</span>
</h1>
- <p className="hero-lede">{home.heroDescription}</p>
- <div className="hero-actions">
- <a className="button" href="#products">
- {home.exploreProducts}
+ <p className="pm-lede">{hero.description}</p>
+ <div className="pm-actions">
+ <a className="button" href="#technology">
+ {hero.explore}
</a>
<a
className="button button-secondary"
- href="https://github.com/Progmasoft"
+ href="https://product.progmasoft.com/"
>
- {home.browseSource}
+ {hero.products} {arrow}
</a>
</div>
- <dl className="hero-facts">
- {home.facts.map(([term, description]) => (
- <div key={term}>
- <dt>{term}</dt>
- <dd>{description}</dd>
- </div>
- ))}
- </dl>
</div>
- <div className="hero-visual" aria-label={home.visualLabel}>
- <div className="system-window">
- <div className="window-bar">
- <span></span>
- <span></span>
- <span></span>
- <code>progmasoft/organization</code>
- </div>
- <div className="system-stack">
- {home.stack.map(([label, title, description], index) => (
- <Fragment key={label}>
- {index > 0 && (
- <div className="stack-arrow" aria-hidden="true">
- ↓
- </div>
- )}
- <div
- className={`stack-row ${["stack-language", "stack-project", "stack-native"][index]}`}
- >
- <span>{label}</span>
- <strong>{title}</strong>
- <small>{description}</small>
- </div>
- </Fragment>
- ))}
+ <div className="pm-pool-visual" aria-label={hero.poolLabel}>
+ {pool.areas.map(([title], index) => (
+ <div className="pm-pool-tile" key={title}>
+ <span className={`pm-icon pm-icon-${areaKinds[index]}`}>
+ <AreaIcon kind={areaKinds[index] ?? "languages"} />
+ </span>
+ <strong>{title}</strong>
</div>
- </div>
+ ))}
</div>
</div>
</section>
- <section className="section" id="products">
- <div className="shell">
- <div className="section-heading">
- <div>
- <p className="eyebrow">{home.productsEyebrow}</p>
- <h2>{home.productsTitle}</h2>
- </div>
- <p>{home.productsDescription}</p>
+ <section className="pm-section" id="technology">
+ <div className="pm-shell">
+ <div className="pm-heading">
+ <p className="eyebrow">{pool.eyebrow}</p>
+ <h2>{pool.title}</h2>
+ <p>{pool.description}</p>
+ </div>
+ <div className="pm-bento">
+ {pool.areas.map(([title, description], index) => (
+ <article
+ className={`pm-card${[0, 3, 4].includes(index) ? " pm-card-wide" : ""}`}
+ key={title}
+ >
+ <span className={`pm-icon pm-icon-${areaKinds[index]}`}>
+ <AreaIcon kind={areaKinds[index] ?? "languages"} />
+ </span>
+ <h3>{title}</h3>
+ <p>{description}</p>
+ </article>
+ ))}
</div>
- <div className="product-grid">
- {home.products.map(
- ([eyebrow, name, description, action], index) => (
- <article
- className={`product-card accent-${productLinks[index]?.accent}`}
- key={name}
- >
- <p className="eyebrow">{eyebrow}</p>
- <h3>{name}</h3>
- <p>{description}</p>
- <a href={productLinks[index]?.href}>
- {action} {forwardArrow(locale)}
- </a>
- </article>
- ),
- )}
+ <div className="pm-catalog">
+ <p>{pool.catalog}</p>
+ <a className="pm-link" href="https://product.progmasoft.com/">
+ {pool.catalogLink} {arrow}
+ </a>
</div>
</div>
</section>
- <section className="section section-muted" id="principles">
- <div className="shell principles-grid">
- <div className="section-heading vertical">
- <p className="eyebrow">{home.principlesEyebrow}</p>
- <h2>{home.principlesTitle}</h2>
- <p>{home.principlesDescription}</p>
+ <section className="pm-section pm-section-muted" id="principles">
+ <div className="pm-shell">
+ <div className="pm-heading">
+ <p className="eyebrow">{principles.eyebrow}</p>
+ <h2>{principles.title}</h2>
</div>
- <div className="principle-list">
- {home.principles.map(([title, description], index) => (
+ <div className="pm-principles">
+ {principles.items.map(([title, description], index) => (
<article key={title}>
<span>{String(index + 1).padStart(2, "0")}</span>
- <div>
- <h3>{title}</h3>
- <p>{description}</p>
- </div>
+ <h3>{title}</h3>
+ <p>{description}</p>
</article>
))}
</div>
</div>
</section>
- <section className="section callout-section">
- <div className="shell callout">
+ <section className="pm-section" id="developers">
+ <div className="pm-shell pm-band">
<div>
- <p className="eyebrow">{home.accountEyebrow}</p>
- <h2>{home.accountTitle}</h2>
- <p>{home.accountDescription}</p>
+ <p className="eyebrow">{developers.eyebrow}</p>
+ <h2>{developers.title}</h2>
+ <p>{developers.description}</p>
+ <div className="pm-actions">
+ <a
+ className="button button-light"
+ href="https://console.progmasoft.com/"
+ >
+ {developers.console}
+ </a>
+ <a
+ className="button button-secondary"
+ href="https://account.progmasoft.com/"
+ >
+ {developers.account}
+ </a>
+ </div>
</div>
- <a
- className="button button-light"
- href="https://account.progmasoft.com/"
- >
- {home.openAccount}
- </a>
+ <pre className="pm-code" dir="ltr">
+ <code>
+ <b>GET</b> https://credentials.progmasoft.com
+ {"\n"}
+ {" "}/.well-known/openid-configuration
+ {"\n\n"}
+ <b>scope</b> openid profile email
+ {"\n"}
+ {" "}viget.packages.read
+ </code>
+ </pre>
</div>
</section>
</main>
<Footer locale={locale} />
- </>
+ </div>
);
}
diff --git a/apps/web/app/products/[slug]/page.tsx b/apps/web/app/products/[slug]/page.tsx
new file mode 100644
index 0000000..4731eba
--- /dev/null
+++ b/apps/web/app/products/[slug]/page.tsx
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Footer } from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { getLocale } from "@/lib/locale.server";
+import { forwardArrow } from "@/lib/localization";
+import { getCatalogMessages, isProductSlug, products } from "@/lib/products";
+
+interface ProductPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateMetadata({
+ params,
+}: ProductPageProps): Promise<Metadata> {
+ const { slug } = await params;
+ if (!isProductSlug(slug)) {
+ return {};
+ }
+ const text = getCatalogMessages(await getLocale()).products[slug];
+ return {
+ title: `${products[slug].name} · ${text.category}`,
+ description: text.description,
+ alternates: { canonical: `https://product.progmasoft.com/${slug}` },
+ };
+}
+
+/** One product: what it is, what it does, and where it lives. */
+export default async function ProductPage({ params }: ProductPageProps) {
+ const { slug } = await params;
+ if (!isProductSlug(slug)) {
+ notFound();
+ }
+ const locale = await getLocale();
+ const catalog = getCatalogMessages(locale);
+ const product = products[slug];
+ const text = catalog.products[slug];
+ const arrow = forwardArrow(locale);
+ return (
+ <div className="pm-page">
+ <SiteHeader locale={locale} />
+ <main>
+ <section className="pm-hero">
+ <div className="pm-shell pm-product-hero">
+ <p className="eyebrow">{text.category}</p>
+ <h1>
+ {product.name} <span>{text.tagline}</span>
+ </h1>
+ <p className="pm-lede">{text.description}</p>
+ </div>
+ </section>
+ <section className="pm-section">
+ <div className="pm-shell pm-detail">
+ <div>
+ <div className="pm-heading">
+ <h2>{catalog.features}</h2>
+ </div>
+ <ul className="pm-features">
+ {text.features.map((feature) => (
+ <li key={feature}>{feature}</li>
+ ))}
+ </ul>
+ </div>
+ <aside className="pm-aside">
+ <span className={`pm-accent pm-accent-${product.accent}`} />
+ <a className="button" href={product.site}>
+ {catalog.site} {arrow}
+ </a>
+ <a className="button button-secondary" href={product.source}>
+ {catalog.source}
+ </a>
+ <span className="pm-note" dir="ltr">
+ {product.site.replace(/^https:\/\//, "").replace(/\/$/, "")}
+ </span>
+ <Link className="pm-link" href="/">
+ {catalog.back}
+ </Link>
+ </aside>
+ </div>
+ </section>
+ </main>
+ <Footer locale={locale} />
+ </div>
+ );
+}
diff --git a/apps/web/app/products/layout.tsx b/apps/web/app/products/layout.tsx
new file mode 100644
index 0000000..c57d103
--- /dev/null
+++ b/apps/web/app/products/layout.tsx
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { ReactNode } from "react";
+import "../site.css";
+
+export default function ProductsLayout({
+ children,
+}: Readonly<{ children: ReactNode }>) {
+ return children;
+}
diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx
new file mode 100644
index 0000000..6b160ab
--- /dev/null
+++ b/apps/web/app/products/page.tsx
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+import type { Metadata } from "next";
+import { Footer } from "@/components/Footer";
+import { SiteHeader } from "@/components/SiteHeader";
+import { getLocale } from "@/lib/locale.server";
+import { forwardArrow } from "@/lib/localization";
+import { getCatalogMessages, productSlugs, products } from "@/lib/products";
+
+export async function generateMetadata(): Promise<Metadata> {
+ const catalog = getCatalogMessages(await getLocale());
+ return {
+ title: catalog.metadataTitle,
+ description: catalog.metadataDescription,
+ al