Add PostgreSQL support with SQLite/Postgres database compatibility layer
- Add dbGet/dbAll helper functions for database-agnostic queries - Add toDbBool/convertBooleansForDb for boolean type conversion - Add toDbDate/getNow for timestamp type handling - Add generateId that returns UUID for Postgres, nanoid for SQLite - Update all routes to use compatibility helpers - Add normalizeEvent to return clean number types from Postgres decimal - Add formatPrice utility for consistent price display - Add legal pages admin interface with RichTextEditor - Update carousel images - Add drizzle migration files for PostgreSQL
This commit is contained in:
@@ -967,3 +967,74 @@ export const siteSettingsApi = {
|
||||
getTimezones: () =>
|
||||
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
|
||||
};
|
||||
|
||||
// ==================== Legal Pages Types ====================
|
||||
|
||||
export interface LegalPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string | null;
|
||||
contentText: string;
|
||||
contentTextEs?: string | null;
|
||||
contentMarkdown: string;
|
||||
contentMarkdownEs?: string | null;
|
||||
updatedAt: string;
|
||||
updatedBy?: string | null;
|
||||
createdAt: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
export interface LegalPagePublic {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
updatedAt?: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
}
|
||||
|
||||
export interface LegalPageListItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Legal Pages API ====================
|
||||
|
||||
export const legalPagesApi = {
|
||||
// Public endpoints
|
||||
getAll: (locale?: string) =>
|
||||
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
getBySlug: (slug: string, locale?: string) =>
|
||||
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
// Admin endpoints
|
||||
getAdminList: () =>
|
||||
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
|
||||
|
||||
getAdminPage: (slug: string) =>
|
||||
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
|
||||
|
||||
update: (slug: string, data: {
|
||||
contentMarkdown?: string;
|
||||
contentMarkdownEs?: string;
|
||||
title?: string;
|
||||
titleEs?: string;
|
||||
}) =>
|
||||
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
seed: () =>
|
||||
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -16,8 +16,11 @@ export interface LegalPageMeta {
|
||||
// Map file names to display titles
|
||||
const titleMap: Record<string, { en: string; es: string }> = {
|
||||
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'terms-policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
};
|
||||
|
||||
// Convert file name to URL-friendly slug
|
||||
@@ -70,8 +73,8 @@ export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Get a specific legal page content
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
// Get a specific legal page content from filesystem (fallback)
|
||||
export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
const legalDir = getLegalDir();
|
||||
const fileName = slugToFileName(slug);
|
||||
const filePath = path.join(legalDir, fileName);
|
||||
@@ -82,7 +85,7 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const baseFileName = fileName.replace('.md', '');
|
||||
const titles = titleMap[baseFileName];
|
||||
const titles = titleMap[baseFileName] || titleMap[slug];
|
||||
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||
|
||||
// Try to extract last updated date from content
|
||||
@@ -96,3 +99,43 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
// Try to fetch from API with locale parameter
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/legal-pages/${slug}?locale=${locale}`, {
|
||||
next: { revalidate: 60 }, // Cache for 60 seconds
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const page = data.page;
|
||||
|
||||
if (page) {
|
||||
// Extract last updated from content or use updatedAt
|
||||
const lastUpdatedMatch = page.contentMarkdown?.match(/Last updated:\s*(.+)/i);
|
||||
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : page.updatedAt;
|
||||
|
||||
return {
|
||||
slug: page.slug,
|
||||
title: page.title, // API already returns localized title with fallback
|
||||
content: page.contentMarkdown, // API already returns localized content with fallback
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch legal page from API, falling back to filesystem:', error);
|
||||
}
|
||||
|
||||
// Fallback to filesystem
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
// Legacy sync function for backwards compatibility
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
36
frontend/src/lib/utils.ts
Normal file
36
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Format price - shows decimals only if needed
|
||||
* Uses space as thousands separator (common in Paraguay)
|
||||
* Examples:
|
||||
* 45000 PYG -> "45 000 PYG" (no decimals)
|
||||
* 41.44 PYG -> "41,44 PYG" (with decimals)
|
||||
*/
|
||||
export function formatPrice(price: number, currency: string = 'PYG'): string {
|
||||
const hasDecimals = price % 1 !== 0;
|
||||
|
||||
// Format the integer and decimal parts separately
|
||||
const intPart = Math.floor(Math.abs(price));
|
||||
const decPart = Math.abs(price) - intPart;
|
||||
|
||||
// Format integer part with space as thousands separator
|
||||
const intFormatted = intPart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
// Build final string
|
||||
let result = price < 0 ? '-' : '';
|
||||
result += intFormatted;
|
||||
|
||||
// Add decimals only if present
|
||||
if (hasDecimals) {
|
||||
const decStr = decPart.toFixed(2).substring(2); // Get just the decimal digits
|
||||
result += ',' + decStr;
|
||||
}
|
||||
|
||||
return `${result} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount (alias for formatPrice for backward compatibility)
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(amount, currency);
|
||||
}
|
||||
Reference in New Issue
Block a user