2 Commits
Author SHA1 Message Date
davide bd920627fa Add CHANGELOG.md
Keep a Changelog format, covering 1.0.0 through the upcoming 2.1.0
release.
2026-09-11 15:43:57 +02:00
davide ac536bd152 Add an English translation of the corralito training-courses page
It was deliberately left Italian-only when /en support was first added;
now translated and following the same thin-page + shared-view pattern
as every other route, with the nav link and language switch no longer
special-casing it.
2026-09-11 15:39:31 +02:00
9 changed files with 271 additions and 148 deletions
+98
View File
@@ -0,0 +1,98 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.0] - 2026-09-11
### Added
- English version of the site (`/en/*`) alongside the Italian original, hand-rolled without an
i18n library: `/en`, `/en/blog`, `/en/blog/[slug]`, `/en/category/[slug]` and the corralito page
all get an English counterpart, sharing markup with their Italian page via a `locale` prop.
- Manual, per-record English fields on content: `titleEn`/`contentEn`/`coverAltEn` on articles,
`nameEn` on categories — translated by the editor in the admin UI, no auto-translation.
- English translation of the corralito training-courses page specifically (it was left Italian-only
when `/en` support first landed, then translated in a follow-up pass).
- Numbered pagination on the blog archive and category pages (first, last, current page and its
neighbors, collapsing gaps into `…`), replacing plain previous/next links.
- `claude-md-management` and `ui-ux-pro-max` Claude Code plugins, enabled repo-wide via
`.claude/settings.json`.
### Changed
- Redesigned the language switch: both languages are shown at once ("Italiano / English"), the
current one as plain non-interactive text, moved to the header's top-right corner instead of
stacked under the logo.
### Fixed
- An article/category with no English translation now 404s cleanly on its own `/en` page and is
filtered out of `/en` listings, instead of silently showing Italian text on an English URL.
- Pagination links no longer report every page as "current" to assistive technology — caused by
`NuxtLink`/`RouterLink`'s built-in active-state detection comparing only the route path, not its
query string.
## [2.0.0] - 2026-09-11
### Added
- `docs/` — architecture, content model, and frontend reference documentation.
- ESLint, via the official `@nuxt/eslint` module.
- Full test suite: unit (Vitest), integration (a real Nitro server against an ephemeral
PocketBase), and end-to-end (Playwright) — under `tests/` at the repo root.
### Changed
- Lightened the (still Strapi-based, at the time) CMS: dropped the unused cloud plugin, disabled
telemetry and promotional UI — a stopgap before the PocketBase migration below.
- Replaced the Strapi + PostgreSQL CMS with PocketBase: a single binary with embedded SQLite and
per-collection API rules. Frontend endpoints moved to `/content/*`; `/api/*` is now reserved for
PocketBase's own REST/file API.
- Local development now bind-mounts PocketBase's data directory (`pocketbase/pb_data/`) instead of
using a named Docker volume, so test data can be inspected or swapped directly on the host.
- `articles.content` now has an explicit 10000-character cap, instead of relying on an undocumented
PocketBase default that silently rejected longer articles.
## [1.0.0] - 2026-08-28
Initial release.
### Added
- Strapi CMS with `Article` and `Category` content types.
- Nuxt frontend: blog index, article detail, and category pages.
- Docker Compose stack: PostgreSQL, Caddy reverse proxy, and the app services.
- Project documentation, license, and `.gitignore`; docs for the stack, dev workflow, production
deployment, and generating `.env` secrets.
- Editorial-magazine restyle of the frontend, with the CorralitoComing seal as site logo and
favicon (every size).
- Contact address and official social-brand links in the footer.
- Author name and publication time shown on articles.
- Responsive layout for phone, tablet, and desktop.
- Landing page content and a dedicated courses page.
- A language switcher supporting English, Italian, Spanish, and French (later removed — see
Removed, below).
### Changed
- Renamed the site to CorralitoComing and switched the interface to English (later reverted to
Italian-only, see Removed).
- Simplified the home page and navigation down to a single "read the latest article" link.
- The CMS admin panel is served under `/admin` on the public domain; every Strapi plugin path is
routed there, not just `/admin`.
### Removed
- The English/Italian/Spanish/French language switcher — reverted to an Italian-only interface,
relying on the visitor's browser to translate the page instead.
### Fixed
- ACME DNS resolution failures in the Caddy container.
### Security
- `ops/` (holds VPS credentials) added to `.gitignore` — never committed.
+5 -4
View File
@@ -8,7 +8,7 @@
| `/blog` | `/en/blog` | `app/pages/blog/index.vue` + `app/pages/en/blog/index.vue``BlogIndexView.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
| `/blog/[slug]` | `/en/blog/[slug]` | `app/pages/blog/[slug].vue` + `app/pages/en/blog/[slug].vue``ArticleView.vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
| `/category/[slug]` | `/en/category/[slug]` | `app/pages/category/[slug].vue` + `app/pages/en/category/[slug].vue``CategoryView.vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
| `/come-difendersi-dal-corralito` | *(none)* | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data, Italian only — see [English content](#english-content-1). |
| `/come-difendersi-dal-corralito` | `/en/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` + `app/pages/en/come-difendersi-dal-corralito.vue``CorralitoView.vue` | Fully static marketing page, no PocketBase data — copy lives in `STRINGS[locale].corralito` (`shared/utils/i18n.ts`), same pattern as every other view. |
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
@@ -47,8 +47,9 @@ Nitro locale-awareness, and most of the SEO work are custom code either way).
by the time a page component's own `<script setup>` would set that state, the header has already
resolved and rendered with whatever the default was — middleware runs, and fully resolves,
before the layout/page tree starts rendering at all, which is the only way to avoid that race.
When unavailable, the switch falls back to the other language's blog index (articles/categories)
or home page (the static corralito page, which has no English version at all).
When unavailable, the switch falls back to the other language's blog index/category list; every
other route (including the static corralito page) exists in both languages unconditionally, so
the mechanical `/en` prefix swap is always valid there.
- **SEO**: `app/composables/useSeo.ts` centralises canonical URL, Open Graph, JSON-LD, and
`hreflang` alternate links (`it`, `en`, `x-default`) for a given `{ locale, path, ... }``path`
is always the bare, unprefixed path, so IT/EN pages for the same content always agree on each
@@ -166,7 +167,7 @@ layout shift results.
render its own `<a>``RouterLink`'s built-in active-class/`aria-current` detection compares
only the route's `path`, not its query string, so with plain `NuxtLink`s every `?page=N` link
would end up (wrongly) marked as the current page.
- `app/components/views/*.vue` (`HomeView`, `BlogIndexView`, `ArticleView`, `CategoryView`) — the
- `app/components/views/*.vue` (`HomeView`, `BlogIndexView`, `ArticleView`, `CategoryView`, `CorralitoView`) — the
actual page markup/logic, parametrized by a `locale` prop; the real `pages/**` and `pages/en/**`
files are thin wrappers around these (see [English content](#english-content)). Registered
without Nuxt's default nested-directory name prefix
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { Locale } from '#shared/utils/locale'
import { STRINGS } from '#shared/utils/i18n'
const props = defineProps<{ locale: Locale }>()
const strings = computed(() => STRINGS[props.locale])
const { public: config } = useRuntimeConfig()
useSeo({
locale: props.locale,
path: '/come-difendersi-dal-corralito',
title: strings.value.corralito.metaTitle,
description: strings.value.corralito.metaDescription,
image: `${config.siteUrl}/protesta-bancaria.jpg`,
})
</script>
<template>
<article>
<header class="article-header">
<h1>{{ strings.navCorralito }}</h1>
</header>
<img
class="cover"
src="/protesta-bancaria.jpg"
:alt="strings.corralito.coverAlt"
width="770"
height="420"
>
<div class="prose">
<p>{{ strings.corralito.p1 }}</p>
<p>{{ strings.corralito.p2 }}</p>
<p>{{ strings.corralito.topicsIntro }}</p>
<ul>
<li v-for="topic in strings.corralito.topics" :key="topic">{{ topic }}</li>
</ul>
<p>{{ strings.corralito.disclaimer }}</p>
</div>
<div class="contact-cta">
<p class="kicker">{{ strings.corralito.contactKicker }}</p>
<p class="contact-links">
<a :href="`mailto:${SITE_EMAIL}`">{{ strings.corralito.emailLink }}</a>
<span aria-hidden="true">·</span>
<a href="https://t.me/Ito1505" target="_blank" rel="noopener">{{ strings.corralito.telegramLink }}</a>
</p>
</div>
</article>
</template>
<style scoped>
.article-header {
max-width: 46rem;
margin: 0 auto 2rem;
text-align: center;
}
@media (min-width: 48rem) {
.article-header {
margin-bottom: 3rem;
}
}
.cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin-block: 0 3rem;
background: var(--color-surface);
}
.prose {
margin-inline: auto;
}
.contact-cta {
max-width: var(--width-prose);
margin: 3rem auto 0;
padding-top: 2rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.contact-links {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.contact-links a {
padding-bottom: 0.35rem;
text-decoration: none;
border-bottom: 1px solid var(--color-ink);
}
.contact-links a:hover {
color: var(--color-ink);
}
</style>
+4 -4
View File
@@ -27,8 +27,8 @@ const strings = computed(() => STRINGS[locale.value])
<ul>
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
<li v-if="locale === 'it'">
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
<li>
<NuxtLink :to="localePath(locale, '/come-difendersi-dal-corralito')">{{ strings.navCorralito }}</NuxtLink>
</li>
</ul>
</div>
@@ -55,8 +55,8 @@ const strings = computed(() => STRINGS[locale.value])
<ul>
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
<li v-if="locale === 'it'">
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
<li>
<NuxtLink :to="localePath(locale, '/come-difendersi-dal-corralito')">{{ strings.navCorralito }}</NuxtLink>
</li>
</ul>
</nav>
@@ -38,10 +38,5 @@ export default defineNuxtRouteMiddleware(async (to) => {
return
}
if (bare === '/come-difendersi-dal-corralito') {
langSwitch.value = { available: false, fallback: '/en' }
return
}
langSwitch.value = { available: true, fallback: '/en' }
})
@@ -1,132 +1,3 @@
<script setup lang="ts">
const { public: config } = useRuntimeConfig()
const route = useRoute()
const canonical = computed(() => `${config.siteUrl}${route.path}`)
const title = 'Corsi di formazione sulle criptovalute'
const description = 'Corsi pratici, individuali o di gruppo, per capire come funzionano le criptovalute e difendersi dal corralito.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogType: 'website',
ogUrl: canonical,
ogImage: `${config.siteUrl}/protesta-bancaria.jpg`,
})
useHead({ link: [{ rel: 'canonical', href: canonical }] })
</script>
<template>
<article>
<header class="article-header">
<h1>Come difendersi dal corralito</h1>
</header>
<img
class="cover"
src="/protesta-bancaria.jpg"
alt="Manifesti e scritte contro le banche durante una crisi bancaria"
width="770"
height="420"
>
<div class="prose">
<p>
Stai pensando di avvicinarti al mondo delle criptovalute (un mercato parallelo al
mercato tradizionale finanziario), ma non sai da dove cominciare?
</p>
<p>
È possibile organizzare corsi individuali o di gruppo dedicati alla conoscenza e al
funzionamento delle criptovalute, con un approccio prevalentemente pratico e senza
fornire consigli o raccomandazioni finanziarie.
</p>
<p>Durante il corso possiamo affrontare, tra gli altri, questi argomenti:</p>
<ul>
<li>quali sono i principali tipi di criptovalute e quali differenze esistono tra loro;</li>
<li>come funziona una blockchain e cosa significa realmente effettuare una transazione;</li>
<li>dove e come vengono conservate le criptovalute: wallet, exchange e sistemi di custodia;</li>
<li>come acquistare e vendere criptovalute e quali sono le principali modalità disponibili;</li>
<li>come utilizzare un wallet e gestire correttamente le proprie chiavi;</li>
<li>quali sono i principali rischi legati alla sicurezza e alla custodia;</li>
<li>come orientarsi nel vastissimo universo delle criptovalute e dei token.</li>
</ul>
<p>
Non si tratta di consulenza finanziaria e non verranno fornite indicazioni su quali
criptovalute acquistare o vendere. L'obiettivo è fornire conoscenze e strumenti per
comprendere questo nuovo mondo e muoversi al suo interno con maggiore consapevolezza.
</p>
</div>
<div class="contact-cta">
<p class="kicker">Per informazioni sui corsi, modalità e disponibilità</p>
<p class="contact-links">
<a :href="`mailto:${SITE_EMAIL}`">Scrivimi via email</a>
<span aria-hidden="true">·</span>
<a href="https://t.me/Ito1505" target="_blank" rel="noopener">Scrivimi su Telegram</a>
</p>
</div>
</article>
<CorralitoView locale="it" />
</template>
<style scoped>
.article-header {
max-width: 46rem;
margin: 0 auto 2rem;
text-align: center;
}
@media (min-width: 48rem) {
.article-header {
margin-bottom: 3rem;
}
}
.cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin-block: 0 3rem;
background: var(--color-surface);
}
.prose {
margin-inline: auto;
}
.contact-cta {
max-width: var(--width-prose);
margin: 3rem auto 0;
padding-top: 2rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.contact-links {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.contact-links a {
padding-bottom: 0.35rem;
text-decoration: none;
border-bottom: 1px solid var(--color-ink);
}
.contact-links a:hover {
color: var(--color-ink);
}
</style>
@@ -0,0 +1,3 @@
<template>
<CorralitoView locale="en" />
</template>
+43 -1
View File
@@ -62,6 +62,27 @@ export const STRINGS = {
byAuthor: 'Di {author}',
backToArticles: 'Torna a tutti gli articoli',
},
corralito: {
metaTitle: 'Corsi di formazione sulle criptovalute',
metaDescription: 'Corsi pratici, individuali o di gruppo, per capire come funzionano le criptovalute e difendersi dal corralito.',
coverAlt: 'Manifesti e scritte contro le banche durante una crisi bancaria',
p1: 'Stai pensando di avvicinarti al mondo delle criptovalute (un mercato parallelo al mercato tradizionale finanziario), ma non sai da dove cominciare?',
p2: "È possibile organizzare corsi individuali o di gruppo dedicati alla conoscenza e al funzionamento delle criptovalute, con un approccio prevalentemente pratico e senza fornire consigli o raccomandazioni finanziarie.",
topicsIntro: 'Durante il corso possiamo affrontare, tra gli altri, questi argomenti:',
topics: [
'quali sono i principali tipi di criptovalute e quali differenze esistono tra loro;',
'come funziona una blockchain e cosa significa realmente effettuare una transazione;',
'dove e come vengono conservate le criptovalute: wallet, exchange e sistemi di custodia;',
'come acquistare e vendere criptovalute e quali sono le principali modalità disponibili;',
'come utilizzare un wallet e gestire correttamente le proprie chiavi;',
'quali sono i principali rischi legati alla sicurezza e alla custodia;',
'come orientarsi nel vastissimo universo delle criptovalute e dei token.',
],
disclaimer: "Non si tratta di consulenza finanziaria e non verranno fornite indicazioni su quali criptovalute acquistare o vendere. L'obiettivo è fornire conoscenze e strumenti per comprendere questo nuovo mondo e muoversi al suo interno con maggiore consapevolezza.",
contactKicker: 'Per informazioni sui corsi, modalità e disponibilità',
emailLink: 'Scrivimi via email',
telegramLink: 'Scrivimi su Telegram',
},
},
en: {
skipLink: 'Skip to content',
@@ -69,7 +90,7 @@ export const STRINGS = {
navAriaLabel: 'Main navigation',
navHome: 'Home',
navBlog: 'All articles',
navCorralito: '',
navCorralito: 'How to protect yourself from the corralito',
footerNavAriaLabel: 'Navigate',
footerNavigaKicker: 'Navigate',
footerSeguiciKicker: 'Follow us',
@@ -118,6 +139,27 @@ export const STRINGS = {
byAuthor: 'By {author}',
backToArticles: 'Back to all articles',
},
corralito: {
metaTitle: 'Cryptocurrency training courses',
metaDescription: 'Practical, individual or group courses to understand how cryptocurrencies work and protect yourself from the corralito.',
coverAlt: 'Banners and graffiti against banks during a banking crisis',
p1: "Are you thinking about getting into the world of cryptocurrencies (a market parallel to the traditional financial market), but don't know where to start?",
p2: 'I can organize individual or group courses dedicated to understanding how cryptocurrencies work, with a mostly hands-on approach and without giving financial advice or recommendations.',
topicsIntro: 'During the course we can cover, among others, these topics:',
topics: [
'the main types of cryptocurrencies and the differences between them;',
'how a blockchain works and what actually happens when you make a transaction;',
'where and how cryptocurrencies are stored: wallets, exchanges and custody systems;',
'how to buy and sell cryptocurrencies and the main methods available;',
'how to use a wallet and correctly manage your own keys;',
'the main risks related to security and custody;',
'how to find your way around the vast universe of cryptocurrencies and tokens.',
],
disclaimer: 'This is not financial advice, and no indications will be given on which cryptocurrencies to buy or sell. The goal is to provide knowledge and tools to understand this new world and navigate it with greater awareness.',
contactKicker: 'For information on courses, format and availability',
emailLink: 'Email me',
telegramLink: 'Message me on Telegram',
},
},
} as const satisfies Record<Locale, unknown>
+7 -4
View File
@@ -41,11 +41,14 @@ test('language switch falls back to the English blog index for an untranslated a
await expect(languageSwitch).toHaveAttribute('href', '/en/blog')
})
test('language switch on the Italian-only corralito page falls back to the English home page', async ({
page,
}) => {
test('the corralito page has an English translation', async ({ page }) => {
await page.goto('/come-difendersi-dal-corralito')
const languageSwitch = page.getByRole('link', { name: 'English' })
await expect(languageSwitch).toHaveAttribute('href', '/en')
await expect(languageSwitch).toHaveAttribute('href', '/en/come-difendersi-dal-corralito')
await page.goto('/en/come-difendersi-dal-corralito')
await expect(
page.getByRole('heading', { level: 1, name: 'How to protect yourself from the corralito' })
).toBeVisible()
})