feat(ADM-018): completed feature
This commit is contained in:
141
project/storefront/src/lib/api.ts
Normal file
141
project/storefront/src/lib/api.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'server-only';
|
||||
|
||||
export interface ProductImageDto {
|
||||
id: string;
|
||||
productId: string;
|
||||
variantId: string | null;
|
||||
url: string;
|
||||
altText: string;
|
||||
position: number;
|
||||
role: 'main' | 'gallery';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CategoryDto {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CategoryTreeNodeDto extends CategoryDto {
|
||||
children: CategoryTreeNodeDto[];
|
||||
}
|
||||
|
||||
export interface BrandDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProductSummaryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
images?: ProductImageDto[];
|
||||
description: string | null;
|
||||
state: 'draft' | 'active' | 'archived';
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
categoryIds: string[];
|
||||
brandId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ProductSearchResponse {
|
||||
items: ProductSummaryDto[];
|
||||
}
|
||||
|
||||
interface CategoryTreeResponse {
|
||||
items: CategoryTreeNodeDto[];
|
||||
}
|
||||
|
||||
interface BrandListResponse {
|
||||
items: BrandDto[];
|
||||
}
|
||||
|
||||
export interface SearchProductsInput {
|
||||
q?: string;
|
||||
brandSlug?: string;
|
||||
categorySlug?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:3000';
|
||||
|
||||
function apiBaseUrl(): string {
|
||||
return (
|
||||
process.env.API_BASE_URL ??
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||
DEFAULT_API_BASE_URL
|
||||
).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function toQueryString(input: SearchProductsInput): string {
|
||||
const params = new URLSearchParams();
|
||||
if (input.q !== undefined && input.q.trim() !== '') params.set('q', input.q.trim());
|
||||
if (input.brandSlug !== undefined && input.brandSlug.trim() !== '') {
|
||||
params.set('brandSlug', input.brandSlug.trim());
|
||||
}
|
||||
if (input.categorySlug !== undefined && input.categorySlug.trim() !== '') {
|
||||
params.set('categorySlug', input.categorySlug.trim());
|
||||
}
|
||||
if (input.limit !== undefined) params.set('limit', String(input.limit));
|
||||
if (input.offset !== undefined) params.set('offset', String(input.offset));
|
||||
const query = params.toString();
|
||||
return query === '' ? '' : `?${query}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl()}${path}`, {
|
||||
headers: { accept: 'application/json' },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Public API request failed with status ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function searchProducts(
|
||||
input: SearchProductsInput = {},
|
||||
): Promise<ProductSummaryDto[]> {
|
||||
const payload = await fetchJson<ProductSearchResponse>(`/products/search${toQueryString(input)}`);
|
||||
return payload.items;
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string): Promise<ProductSummaryDto> {
|
||||
return fetchJson<ProductSummaryDto>(`/productos/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function getCategoryBySlug(slug: string): Promise<CategoryDto> {
|
||||
return fetchJson<CategoryDto>(`/categoria/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function listCategoryTree(): Promise<CategoryTreeNodeDto[]> {
|
||||
const payload = await fetchJson<CategoryTreeResponse>('/categories/tree');
|
||||
return payload.items;
|
||||
}
|
||||
|
||||
export async function getBrandBySlug(slug: string): Promise<BrandDto> {
|
||||
return fetchJson<BrandDto>(`/marca/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function listBrands(): Promise<BrandDto[]> {
|
||||
const payload = await fetchJson<BrandListResponse>('/brands');
|
||||
return payload.items;
|
||||
}
|
||||
13
project/storefront/src/lib/seo.ts
Normal file
13
project/storefront/src/lib/seo.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const SITE_NAME = 'MercadoDeVida';
|
||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3001').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export function metadataTitle(title: string): string {
|
||||
return `${title} | ${SITE_NAME}`;
|
||||
}
|
||||
56
project/storefront/src/lib/seo/json-ld.tsx
Normal file
56
project/storefront/src/lib/seo/json-ld.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ProductSummaryDto } from '@/lib/api';
|
||||
import { absoluteUrl, SITE_NAME, SITE_URL } from '@/lib/seo';
|
||||
|
||||
export type JsonLd = Readonly<Record<string, unknown>>;
|
||||
|
||||
export function organizationJsonLd(): JsonLd {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbJsonLd(items: ReadonlyArray<{ name: string; path: string }>): JsonLd {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: absoluteUrl(item.path),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function productJsonLd(product: ProductSummaryDto): JsonLd {
|
||||
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
|
||||
return withoutUndefined({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.name,
|
||||
description: product.description ?? product.seoDescription ?? undefined,
|
||||
url: absoluteUrl(product.url),
|
||||
image: image ? absoluteCatalogUrl(image.url) : undefined,
|
||||
sku: product.slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function JsonLdScript({ data }: Readonly<{ data: JsonLd | readonly JsonLd[] }>) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteCatalogUrl(value: string): string {
|
||||
return URL.canParse(value) ? value : absoluteUrl(value);
|
||||
}
|
||||
|
||||
function withoutUndefined(input: JsonLd): JsonLd {
|
||||
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
58
project/storefront/src/lib/seo/redirects.ts
Normal file
58
project/storefront/src/lib/seo/redirects.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface StoredRedirect {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface RedirectRepository {
|
||||
findBySourcePath(pathname: string): StoredRedirect | undefined;
|
||||
}
|
||||
|
||||
export class EnvRedirectRepository implements RedirectRepository {
|
||||
private readonly redirects: readonly StoredRedirect[];
|
||||
|
||||
constructor(rawJson = process.env.REDIRECTS_JSON) {
|
||||
this.redirects = parseRedirects(rawJson);
|
||||
}
|
||||
|
||||
findBySourcePath(pathname: string): StoredRedirect | undefined {
|
||||
return this.redirects.find((redirect) => redirect.from === normalizePath(pathname));
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRedirects(rawJson: string | undefined): readonly StoredRedirect[] {
|
||||
if (rawJson === undefined || rawJson.trim() === '') return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.flatMap((entry) => {
|
||||
if (!isRedirectEntry(entry)) return [];
|
||||
const from = normalizePath(entry.from);
|
||||
const to = normalizePath(entry.to);
|
||||
if (!isSafeLocalPath(from) || !isSafeLocalPath(to) || from === to) return [];
|
||||
return [{ from, to }];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectEntry(value: unknown): value is StoredRedirect {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'from' in value &&
|
||||
'to' in value &&
|
||||
typeof value.from === 'string' &&
|
||||
typeof value.to === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeLocalPath(pathname: string): boolean {
|
||||
return pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.includes('://');
|
||||
}
|
||||
|
||||
function normalizePath(pathname: string): string {
|
||||
if (pathname.length > 1) return pathname.replace(/\/+$/, '');
|
||||
return pathname;
|
||||
}
|
||||
Reference in New Issue
Block a user