Home

Integrazione Angular

API pubblica articoli

Endpoint REST in sola lettura. CORS aperto. Restituisce solo articoli pubblicati. Il contenuto è HTML pronto per essere renderizzato (grassetti, titoli, immagini, tabelle, link).

Endpoint
Base URL del pannello CMS

Lista articoli (paginata)

GET https://your-domain.com/api/public/articles?page=1&limit=10&search=tarocchi

Parametri: page (default 1), limit (1-100, default 10), search (opzionale).

Singolo articolo per slug

GET https://your-domain.com/api/public/articles/{slug}
Esempio risposta lista
{
  "data": [
    {
      "id": "uuid",
      "slug": "tarocchi-amore",
      "title": "Tarocchi e amore",
      "excerpt": "Breve riassunto...",
      "content": "<p>HTML completo</p>",
      "featured_image_url": "https://.../immagine.jpg",
      "meta_title": "Tarocchi e amore | Cartomanzia delle Fate",
      "meta_description": "Scopri come...",
      "published_at": "2025-03-12T10:00:00Z"
    }
  ],
  "page": 1,
  "limit": 10,
  "total": 122
}
1. Service Angular
Copia in src/app/services/articles.service.ts
// src/app/services/articles.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Article {
  id: string;
  slug: string;
  title: string;
  excerpt: string | null;
  content: string | null;            // HTML completo (con grassetti, immagini, titoli...)
  featured_image_url: string | null;
  meta_title: string | null;
  meta_description: string | null;
  published_at: string | null;       // ISO date
}

export interface ArticleListResponse {
  data: Article[];
  page: number;
  limit: number;
  total: number;
}

@Injectable({ providedIn: 'root' })
export class ArticlesService {
  private http = inject(HttpClient);
  private base = 'https://your-domain.com/api/public/articles';

  list(page = 1, limit = 10, search?: string): Observable<ArticleListResponse> {
    let params = new HttpParams()
      .set('page', String(page))
      .set('limit', String(limit));
    if (search) params = params.set('search', search);
    return this.http.get<ArticleListResponse>(this.base, { params });
  }

  bySlug(slug: string): Observable<Article> {
    return this.http.get<Article>(`${this.base}/${slug}`);
  }
}
2. Configurazione HttpClient
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top' })),
    provideHttpClient(),
  ],
};
3. Lista con paginazione
Pulsanti precedente/successiva, sincronizzazione con ?page=N nella URL, rel="prev" / rel="next" per il SEO.
// src/app/pages/blog-list.component.ts — CON PAGINAZIONE
import { Component, inject, signal, computed } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ArticlesService, Article } from '../services/articles.service';
import { Title, Meta } from '@angular/platform-browser';

const PAGE_SIZE = 10;
const SITE_URL = 'https://www.cartomanziadellefate.it';

@Component({
  standalone: true,
  imports: [RouterLink],
  template: `
    <h1>Blog</h1>

    @for (a of articles(); track a.id) {
      <article class="post-card">
        @if (a.featured_image_url) {
          <img [src]="a.featured_image_url" [alt]="a.title" loading="lazy" />
        }
        <h2><a [routerLink]="['/blog', a.slug]">{{ a.title }}</a></h2>
        <p>{{ a.excerpt }}</p>
      </article>
    }

    <!-- Paginazione -->
    <nav class="pagination" aria-label="Paginazione articoli">
      <button (click)="goTo(page() - 1)" [disabled]="page() <= 1">‹ Precedente</button>
      <span>Pagina {{ page() }} di {{ totalPages() }}</span>
      <button (click)="goTo(page() + 1)" [disabled]="page() >= totalPages()">Successiva ›</button>
    </nav>
  `,
})
export class BlogListComponent {
  private svc = inject(ArticlesService);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private title = inject(Title);
  private meta = inject(Meta);

  articles = signal<Article[]>([]);
  page = signal(1);
  total = signal(0);
  totalPages = computed(() => Math.max(1, Math.ceil(this.total() / PAGE_SIZE)));

  ngOnInit() {
    // Sincronizza pagina con la URL: /blog?page=2
    this.route.queryParamMap.subscribe(qp => {
      const p = Math.max(1, parseInt(qp.get('page') ?? '1', 10) || 1);
      this.page.set(p);
      this.load(p);
    });
  }

  private load(p: number) {
    this.svc.list(p, PAGE_SIZE).subscribe(res => {
      this.articles.set(res.data);
      this.total.set(res.total);

      // SEO: titolo, descrizione, canonical, rel=prev/next
      const suffix = p > 1 ? ` — Pagina ${p}` : '';
      this.title.setTitle(`Blog${suffix} — Cartomanzia delle Fate`);
      this.meta.updateTag({ name: 'description', content: 'Articoli e guide di cartomanzia' });

      this.setCanonical(p === 1 ? `${SITE_URL}/blog` : `${SITE_URL}/blog?page=${p}`);
      this.setRel('prev', p > 1 ? `${SITE_URL}/blog${p - 1 > 1 ? '?page=' + (p - 1) : ''}` : null);
      this.setRel('next', p < this.totalPages() ? `${SITE_URL}/blog?page=${p + 1}` : null);
    });
  }

  goTo(p: number) {
    this.router.navigate([], { queryParams: { page: p === 1 ? null : p }, queryParamsHandling: 'merge' });
  }

  private setCanonical(href: string) {
    let link = document.querySelector("link[rel='canonical']") as HTMLLinkElement | null;
    if (!link) { link = document.createElement('link'); link.rel = 'canonical'; document.head.appendChild(link); }
    link.href = href;
  }
  private setRel(rel: 'prev' | 'next', href: string | null) {
    let link = document.querySelector(`link[rel='${rel}']`) as HTMLLinkElement | null;
    if (href === null) { link?.remove(); return; }
    if (!link) { link = document.createElement('link'); link.rel = rel; document.head.appendChild(link); }
    link.href = href;
  }
}
4. Dettaglio articolo (SEO completo)
Title, meta description, canonical, Open Graph, Twitter Card, JSON-LD Article — tutto popolato dinamicamente.
// src/app/pages/blog-detail.component.ts — CON SEO COMPLETO
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ArticlesService, Article } from '../services/articles.service';
import { Title, Meta, DomSanitizer, SafeHtml } from '@angular/platform-browser';

const SITE_URL = 'https://www.cartomanziadellefate.it';
const SITE_NAME = 'Cartomanzia delle Fate';

@Component({
  standalone: true,
  template: `
    @if (article(); as a) {
      <article>
        @if (a.featured_image_url) {
          <img [src]="a.featured_image_url" [alt]="a.title" />
        }
        <h1>{{ a.title }}</h1>
        <time *ngIf="a.published_at" [attr.datetime]="a.published_at">
          {{ a.published_at | date:'longDate':'':'it' }}
        </time>
        <div class="post-content" [innerHTML]="content()"></div>
      </article>
    }
  `,
})
export class BlogDetailComponent {
  private route = inject(ActivatedRoute);
  private svc = inject(ArticlesService);
  private title = inject(Title);
  private meta = inject(Meta);
  private sanitizer = inject(DomSanitizer);

  article = signal<Article | null>(null);
  content = signal<SafeHtml>('');

  ngOnInit() {
    const slug = this.route.snapshot.paramMap.get('slug')!;
    this.svc.bySlug(slug).subscribe(a => {
      this.article.set(a);
      this.content.set(this.sanitizer.bypassSecurityTrustHtml(a.content ?? ''));

      const url = `${SITE_URL}/blog/${a.slug}`;
      const pageTitle = a.meta_title ?? a.title;
      const desc = a.meta_description ?? a.excerpt ?? '';

      // 1) <title> e meta description
      this.title.setTitle(pageTitle);
      this.meta.updateTag({ name: 'description', content: desc });

      // 2) Canonical
      this.setCanonical(url);

      // 3) Open Graph
      this.meta.updateTag({ property: 'og:title', content: pageTitle });
      this.meta.updateTag({ property: 'og:description', content: desc });
      this.meta.updateTag({ property: 'og:type', content: 'article' });
      this.meta.updateTag({ property: 'og:url', content: url });
      this.meta.updateTag({ property: 'og:site_name', content: SITE_NAME });
      if (a.featured_image_url) {
        this.meta.updateTag({ property: 'og:image', content: a.featured_image_url });
      }

      // 4) Twitter Card
      this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
      this.meta.updateTag({ name: 'twitter:title', content: pageTitle });
      this.meta.updateTag({ name: 'twitter:description', content: desc });
      if (a.featured_image_url) {
        this.meta.updateTag({ name: 'twitter:image', content: a.featured_image_url });
      }

      // 5) JSON-LD Article (structured data)
      this.injectJsonLd({
        '@context': 'https://schema.org',
        '@type': 'Article',
        headline: a.title,
        description: desc,
        image: a.featured_image_url ?? undefined,
        datePublished: a.published_at,
        author: { '@type': 'Organization', name: SITE_NAME },
        publisher: { '@type': 'Organization', name: SITE_NAME },
        mainEntityOfPage: { '@type': 'WebPage', '@id': url },
      });
    });
  }

  private setCanonical(href: string) {
    let link = document.querySelector("link[rel='canonical']") as HTMLLinkElement | null;
    if (!link) { link = document.createElement('link'); link.rel = 'canonical'; document.head.appendChild(link); }
    link.href = href;
  }
  private injectJsonLd(obj: unknown) {
    document.querySelectorAll('script[data-jsonld="article"]').forEach(s => s.remove());
    const s = document.createElement('script');
    s.type = 'application/ld+json';
    s.setAttribute('data-jsonld', 'article');
    s.textContent = JSON.stringify(obj);
    document.head.appendChild(s);
  }
}
5. Routing
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { BlogListComponent } from './pages/blog-list.component';
import { BlogDetailComponent } from './pages/blog-detail.component';

export const routes: Routes = [
  { path: 'blog', component: BlogListComponent },
  { path: 'blog/:slug', component: BlogDetailComponent },
];
6. CSS per il contenuto
L'editor del pannello produce HTML standard. Incolla questo CSS in styles.css per stilizzarlo lato Angular.
/* Stili minimi per il contenuto generato dall'editor */
.post-content {
  line-height: 1.7;
  color: #1a1a1a;
}
.post-content h2 { font-size: 1.6rem; margin: 2rem 0 1rem; font-weight: 700; }
.post-content h3 { font-size: 1.3rem; margin: 1.5rem 0 0.75rem; font-weight: 700; }
.post-content h4 { font-size: 1.1rem; margin: 1.25rem 0 0.5rem; font-weight: 600; }
.post-content p { margin: 0 0 1rem; }
.post-content ul, .post-content ol { margin: 0 0 1rem 1.5rem; }
.post-content li { margin: 0.25rem 0; }
.post-content blockquote {
  border-left: 4px solid #ccc;
  margin: 1.5rem 0;
  padding: 0.5rem 1rem;
  color: #555;
  font-style: italic;
}
.post-content a { color: #2563eb; text-decoration: underline; }
.post-content img {
  max-width: 100%;
  height: auto;
  border-radius: 6px;
  margin: 1rem 0;
}
.post-content table {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}
.post-content th, .post-content td {
  border: 1px solid #ddd;
  padding: 0.5rem 0.75rem;
}
.post-content th { background: #f5f5f5; font-weight: 600; }
.post-content code {
  background: #f3f4f6;
  padding: 0.1rem 0.3rem;
  border-radius: 3px;
  font-size: 0.9em;
}
.post-content pre {
  background: #1f2937;
  color: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  overflow-x: auto;
}
/* Allineamento testo dall'editor */
.post-content [style*="text-align: center"] { text-align: center; }
.post-content [style*="text-align: right"] { text-align: right; }
.post-content [style*="text-align: justify"] { text-align: justify; }
7. Sitemap.xml
Script Node che legge tutti gli articoli e genera la sitemap. Eseguilo in fase di build o periodicamente.
// scripts/generate-sitemap.ts — esegui in build
// npx ts-node scripts/generate-sitemap.ts > src/sitemap.xml
const API = 'https://your-domain.com/api/public/articles';
const SITE = 'https://www.cartomanziadellefate.it';

async function main() {
  // Recupera TUTTI gli articoli (paginando)
  const all: { slug: string; published_at: string | null }[] = [];
  let page = 1;
  while (true) {
    const res = await fetch(`${API}?page=${page}&limit=100`);
    const json = await res.json();
    all.push(...json.data);
    if (page * json.limit >= json.total) break;
    page++;
  }

  const urls = [
    `  <url><loc>${SITE}/</loc><priority>1.0</priority></url>`,
    `  <url><loc>${SITE}/blog</loc><priority>0.9</priority></url>`,
    ...all.map(a => `  <url>
    <loc>${SITE}/blog/${a.slug}</loc>
    ${a.published_at ? `<lastmod>${a.published_at}</lastmod>` : ''}
    <priority>0.7</priority>
  </url>`),
  ];

  console.log(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`);
}
main();
Architettura consigliata + SEO

Sottodominio del pannello CMS

Pubblica questo pannello su admin.cartomanziadellefate.it. È area privata: il tag noindex, nofollow è già impostato, quindi Google non lo indicizza.

Sito Angular sul dominio principale

Il blog deve vivere su www.cartomanziadellefate.it/blog/... per non disperdere l'authority SEO del dominio. Evita sottodomini tipo articoli. per contenuti pubblici: Google li tratta come sito separato.

CORS dell'API

Attualmente accetta *. Quando vai in produzione possiamo restringere a https://www.cartomanziadellefate.it per maggior sicurezza.

Angular SSR (importante)

Per indicizzazione ottimale considera Angular Universal / SSR: i meta tag iniettati lato client funzionano con Googlebot moderno ma SSR è più affidabile per social preview (Facebook, LinkedIn, WhatsApp) che NON eseguono JavaScript.

URL coerenti

Lo slug coincide con quello WordPress originale, quindi gli URL /blog/nome-articolo restano invariati e non servono redirect.