Link pipeline creatives to RRSS calendar pieces with pick-or-create UI.

Agency can choose a scheduled calendar post to prefill title, copy, pillar and date, or create/edit one in the calendar while adding it to the pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 18:01:11 +02:00
parent 12ecde1b3b
commit c2bbbfcd06
6 changed files with 884 additions and 56 deletions

View File

@@ -0,0 +1,110 @@
/** Read/write RRSS calendar pieces stored in documents (type=calendar). */
import type { RrssPiece } from '@/components/RrssMonthView';
import { monthLabel, slugify } from '@/lib/calendar-import';
export type CalendarDoc = {
id: string;
title: string;
content: unknown;
};
export type CalendarPieceRow = RrssPiece & {
month: string;
document_id: string;
document_title: string;
linked_pipeline?: boolean;
};
export function asPlanObject(content: unknown): Record<string, unknown> | null {
let c = content;
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch {
return null;
}
}
if (!c || typeof c !== 'object') return null;
const o = c as Record<string, unknown>;
if (o.kind === 'rrss_month' && Array.isArray(o.pieces)) return o;
return null;
}
export function piecesFromDocs(docs: CalendarDoc[]): CalendarPieceRow[] {
const out: CalendarPieceRow[] = [];
for (const doc of docs) {
const plan = asPlanObject(doc.content);
if (!plan) continue;
const month = String(plan.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) continue;
const pieces = plan.pieces as RrssPiece[];
for (const p of pieces) {
if (!p?.id || !p.title) continue;
out.push({
...p,
month,
document_id: doc.id,
document_title: doc.title,
});
}
}
out.sort((a, b) => {
const da = `${a.date} ${a.time || ''}`.localeCompare(`${b.date} ${b.time || ''}`);
return da || a.title.localeCompare(b.title);
});
return out;
}
export function mapFormatToPipeline(format?: string, kind?: string): string {
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
if (hay.includes('stor')) return 'story';
if (hay.includes('reel')) return 'reel';
if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel';
return 'post';
}
export function mapPipelineFormatToCalendar(format: string): {
format: string;
kind: string;
} {
if (format === 'story') return { format: 'Stories', kind: 'story' };
if (format === 'reel') return { format: 'Reel', kind: 'reel' };
if (format === 'carrusel') return { format: 'Carrusel', kind: 'post' };
return { format: 'Post', kind: 'post' };
}
export function buildCaption(piece: Pick<RrssPiece, 'hook' | 'body' | 'cta' | 'hashtags'>): string {
return [piece.hook, piece.body, piece.cta, piece.hashtags]
.filter((x) => x && String(x).trim())
.join('\n\n')
.trim();
}
export function scheduledAtFromPiece(piece: Pick<RrssPiece, 'date' | 'time'>): string | null {
const day = (piece.date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const time = (piece.time || '10:00').slice(0, 5);
const iso = new Date(`${day}T${time}:00`);
if (Number.isNaN(iso.getTime())) return `${day}T10:00:00.000Z`;
return iso.toISOString();
}
export function newPieceId(date: string, title: string): string {
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
}
export function emptyMonthPlan(ym: string, tenantLabel: string) {
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Piezas del plan editorial.',
chips: [],
},
pieces: [] as RrssPiece[],
};
}