255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
import { ContentStatus, ProficiencyLevel } from "@prisma/client";
|
|
|
|
export type AcademicStageId = "base" | "consolidacion" | "especializacion";
|
|
export type AvailabilityState = "published" | "upcoming" | "draft";
|
|
|
|
export type AcademicStageConfig = {
|
|
id: AcademicStageId;
|
|
tabLabel: string;
|
|
sectionTitle: string;
|
|
sectionDescription: string;
|
|
levelLabel: string;
|
|
};
|
|
|
|
export const ACADEMIC_STAGE_ORDER: AcademicStageId[] = ["base", "consolidacion", "especializacion"];
|
|
|
|
export const ACADEMIC_STAGE_CONFIG: Record<AcademicStageId, AcademicStageConfig> = {
|
|
base: {
|
|
id: "base",
|
|
tabLabel: "Base",
|
|
sectionTitle: "Programas Base",
|
|
sectionDescription:
|
|
"Trayectos introductorios para fortalecer fundamentos del inglés jurídico y lenguaje técnico aplicado.",
|
|
levelLabel: "Base",
|
|
},
|
|
consolidacion: {
|
|
id: "consolidacion",
|
|
tabLabel: "Consolidación",
|
|
sectionTitle: "Programas de Consolidación",
|
|
sectionDescription:
|
|
"Programas para consolidar precisión terminológica, comprensión de textos jurídicos y comunicación profesional.",
|
|
levelLabel: "Consolidación",
|
|
},
|
|
especializacion: {
|
|
id: "especializacion",
|
|
tabLabel: "Especialización",
|
|
sectionTitle: "Programas de Especialización",
|
|
sectionDescription:
|
|
"Itinerarios avanzados orientados a práctica profesional internacional, redacción especializada y análisis complejo.",
|
|
levelLabel: "Especialización",
|
|
},
|
|
};
|
|
|
|
const STAGE_KEYWORDS: Record<AcademicStageId, string[]> = {
|
|
base: ["base", "fundamentos", "fundamentals", "intro", "introduccion", "beginner", "inicial"],
|
|
consolidacion: ["consolidacion", "consolidación", "consolidation", "intermedio", "intermediate"],
|
|
especializacion: ["especializacion", "especialización", "specialization", "avanzado", "advanced", "expert", "experto"],
|
|
};
|
|
|
|
export const PUBLIC_UPCOMING_TAGS = ["upcoming", "coming-soon", "proximamente", "próximamente", "public-upcoming"];
|
|
|
|
const IMAGE_KEYS = ["coverImageUrl", "coverImage", "thumbnailUrl", "thumbnail", "imageUrl", "image", "cover"];
|
|
const SHORT_KEYS = ["shortEs", "shortEn", "short", "summary", "excerpt", "resumen"];
|
|
const LONG_KEYS = ["longEs", "longEn", "long", "description", "descripcion"];
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
|
|
function isRecord(value: unknown): value is UnknownRecord {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function fromRecord(record: UnknownRecord, keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = asString(record[key]);
|
|
if (value) return value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function getLocalizedText(value: unknown): string {
|
|
if (!value) return "";
|
|
if (typeof value === "string") return value.trim();
|
|
if (isRecord(value)) {
|
|
const preferred = fromRecord(value, ["es", "en"]);
|
|
if (preferred) return preferred;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function getCourseDescriptions(description: unknown): { short: string; long: string } {
|
|
if (!description) {
|
|
return {
|
|
short: "Programa en actualización académica.",
|
|
long: "Programa en actualización académica.",
|
|
};
|
|
}
|
|
|
|
const localized = getLocalizedText(description);
|
|
|
|
if (!isRecord(description)) {
|
|
const normalized = localized || "Programa en actualización académica.";
|
|
return { short: truncateText(normalized, 170), long: normalized };
|
|
}
|
|
|
|
const shortCandidate = fromRecord(description, SHORT_KEYS);
|
|
const longCandidate = fromRecord(description, LONG_KEYS) || localized;
|
|
const longText = longCandidate || "Programa en actualización académica.";
|
|
const shortText = shortCandidate || truncateText(longText, 170);
|
|
|
|
return {
|
|
short: shortText,
|
|
long: longText,
|
|
};
|
|
}
|
|
|
|
function toTags(input: string[] | null | undefined): string[] {
|
|
if (!Array.isArray(input)) return [];
|
|
return input.filter((value): value is string => typeof value === "string");
|
|
}
|
|
|
|
function getImageFromTags(tags: string[] | null | undefined): string {
|
|
for (const rawTag of toTags(tags)) {
|
|
const tag = rawTag.trim();
|
|
if (!tag) continue;
|
|
const lower = tag.toLowerCase();
|
|
if (lower.startsWith("cover:") || lower.startsWith("cover-url:") || lower.startsWith("thumbnail:")) {
|
|
const value = tag.slice(tag.indexOf(":") + 1).trim();
|
|
if (value.startsWith("http://") || value.startsWith("https://")) return value;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function getYouTubeThumbnail(url: string): string {
|
|
if (!url) return "";
|
|
const normalized = url.trim();
|
|
if (!normalized) return "";
|
|
|
|
const patterns = [/v=([a-zA-Z0-9_-]{6,})/, /youtu\.be\/([a-zA-Z0-9_-]{6,})/, /embed\/([a-zA-Z0-9_-]{6,})/];
|
|
for (const pattern of patterns) {
|
|
const match = normalized.match(pattern);
|
|
const videoId = match?.[1];
|
|
if (videoId) return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function getImageFromDescription(description: unknown): string {
|
|
if (!isRecord(description)) return "";
|
|
const image = fromRecord(description, IMAGE_KEYS);
|
|
if (!image) return "";
|
|
return image.startsWith("http://") || image.startsWith("https://") || image.startsWith("/") ? image : "";
|
|
}
|
|
|
|
function getImageFromVideoUrl(url: string): string {
|
|
if (!url) return "";
|
|
const normalized = url.trim();
|
|
if (!normalized) return "";
|
|
if (/\.(png|jpe?g|webp|gif)$/i.test(normalized)) return normalized;
|
|
return "";
|
|
}
|
|
|
|
export function resolveThumbnailUrl(args: {
|
|
description: unknown;
|
|
tags: string[] | null | undefined;
|
|
youtubeUrl?: string | null;
|
|
videoUrl?: string | null;
|
|
}): string {
|
|
const fromDescription = getImageFromDescription(args.description);
|
|
if (fromDescription) return fromDescription;
|
|
|
|
const fromTags = getImageFromTags(args.tags);
|
|
if (fromTags) return fromTags;
|
|
|
|
const fromYouTube = getYouTubeThumbnail(args.youtubeUrl ?? "");
|
|
if (fromYouTube) return fromYouTube;
|
|
|
|
const fromVideo = getImageFromVideoUrl(args.videoUrl ?? "");
|
|
if (fromVideo) return fromVideo;
|
|
|
|
return "";
|
|
}
|
|
|
|
function normalizeTag(tag: string): string {
|
|
return tag
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "");
|
|
}
|
|
|
|
export function normalizeTags(tags: string[] | null | undefined): string[] {
|
|
return toTags(tags).map(normalizeTag).filter(Boolean);
|
|
}
|
|
|
|
function includesKeyword(tags: string[], stage: AcademicStageId): boolean {
|
|
return tags.some((tag) => STAGE_KEYWORDS[stage].some((keyword) => tag.includes(keyword)));
|
|
}
|
|
|
|
export function resolveAcademicStage(level: ProficiencyLevel, tags: string[] | null | undefined): AcademicStageConfig {
|
|
const normalizedTags = normalizeTags(tags);
|
|
|
|
if (includesKeyword(normalizedTags, "base")) return ACADEMIC_STAGE_CONFIG.base;
|
|
if (includesKeyword(normalizedTags, "consolidacion")) return ACADEMIC_STAGE_CONFIG.consolidacion;
|
|
if (includesKeyword(normalizedTags, "especializacion")) return ACADEMIC_STAGE_CONFIG.especializacion;
|
|
|
|
if (level === "BEGINNER") return ACADEMIC_STAGE_CONFIG.base;
|
|
if (level === "INTERMEDIATE") return ACADEMIC_STAGE_CONFIG.consolidacion;
|
|
return ACADEMIC_STAGE_CONFIG.especializacion;
|
|
}
|
|
|
|
export function resolveAvailability(
|
|
status: ContentStatus,
|
|
tags: string[] | null | undefined,
|
|
): { state: AvailabilityState; label: string } {
|
|
if (status === "PUBLISHED") return { state: "published", label: "Disponible" };
|
|
|
|
const normalizedTags = normalizeTags(tags);
|
|
const hasUpcomingTag = normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
|
if (hasUpcomingTag) return { state: "upcoming", label: "Próximamente" };
|
|
|
|
return { state: "draft", label: "Borrador" };
|
|
}
|
|
|
|
export function isUpcomingTagPresent(tags: string[] | null | undefined): boolean {
|
|
const normalizedTags = normalizeTags(tags);
|
|
return normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
|
}
|
|
|
|
export function getProficiencyLabel(level: ProficiencyLevel): string {
|
|
if (level === "BEGINNER") return "Inicial";
|
|
if (level === "INTERMEDIATE") return "Intermedio";
|
|
if (level === "ADVANCED") return "Avanzado";
|
|
return "Experto";
|
|
}
|
|
|
|
export function formatDuration(totalSeconds: number): string {
|
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "Duración por definir";
|
|
|
|
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60));
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
|
|
if (hours === 0) return `${totalMinutes} min`;
|
|
if (minutes === 0) return `${hours} h`;
|
|
return `${hours} h ${minutes} min`;
|
|
}
|
|
|
|
export function formatMinutes(minutes: number): string {
|
|
if (!Number.isFinite(minutes) || minutes <= 0) return "Sin duración";
|
|
if (minutes < 60) return `${minutes} min`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
if (remainingMinutes === 0) return `${hours} h`;
|
|
return `${hours} h ${remainingMinutes} min`;
|
|
}
|
|
|
|
export function truncateText(value: string, maxChars: number): string {
|
|
if (value.length <= maxChars) return value;
|
|
return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
|
}
|