Harden week-grid parsing (placeholders, status rows, month wrap) and ship an October-shaped plantilla for agency uploads. Co-authored-by: Cursor <cursoragent@cursor.com>
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */
|
||
|
||
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||
|
||
export type SheetRows = (string | number | null)[][];
|
||
|
||
export type ImportResult = {
|
||
plans: RrssMonthPlan[];
|
||
warnings: string[];
|
||
sheets: { name: string; pieces: number; mode: string }[];
|
||
};
|
||
|
||
const MONTHS: Record<string, number> = {
|
||
enero: 1,
|
||
february: 2,
|
||
febrero: 2,
|
||
marzo: 3,
|
||
march: 3,
|
||
abril: 4,
|
||
april: 4,
|
||
mayo: 5,
|
||
may: 5,
|
||
junio: 6,
|
||
june: 6,
|
||
julio: 7,
|
||
july: 7,
|
||
agosto: 8,
|
||
august: 8,
|
||
septiembre: 9,
|
||
septimebre: 9,
|
||
september: 9,
|
||
octubre: 10,
|
||
october: 10,
|
||
noviembre: 11,
|
||
november: 11,
|
||
diciembre: 12,
|
||
december: 12,
|
||
ene: 1,
|
||
feb: 2,
|
||
mar: 3,
|
||
abr: 4,
|
||
jun: 6,
|
||
jul: 7,
|
||
ago: 8,
|
||
sep: 9,
|
||
oct: 10,
|
||
nov: 11,
|
||
dic: 12,
|
||
};
|
||
|
||
export function slugify(s: string) {
|
||
return s
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 48);
|
||
}
|
||
|
||
export function monthLabel(ym: string) {
|
||
const [y, m] = ym.split('-').map(Number);
|
||
const names = [
|
||
'',
|
||
'Enero',
|
||
'Febrero',
|
||
'Marzo',
|
||
'Abril',
|
||
'Mayo',
|
||
'Junio',
|
||
'Julio',
|
||
'Agosto',
|
||
'Septiembre',
|
||
'Octubre',
|
||
'Noviembre',
|
||
'Diciembre',
|
||
];
|
||
return `${names[m] || ym} ${y}`;
|
||
}
|
||
|
||
export function inferYmFromSheetName(name: string, fallbackYear: number): string | null {
|
||
const raw = name.replace(/\s*\(prov\)\s*/i, '').trim();
|
||
const iso = raw.match(/(20\d{2})[-_/ ](\d{1,2})/);
|
||
if (iso) {
|
||
const m = Number(iso[2]);
|
||
if (m >= 1 && m <= 12) return `${iso[1]}-${String(m).padStart(2, '0')}`;
|
||
}
|
||
const folded = raw
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '');
|
||
let month: number | null = null;
|
||
for (const [k, v] of Object.entries(MONTHS)) {
|
||
if (folded.includes(k)) {
|
||
month = v;
|
||
break;
|
||
}
|
||
}
|
||
if (!month) return null;
|
||
const yMatch = folded.match(/20\d{2}/);
|
||
let year = yMatch ? Number(yMatch[0]) : fallbackYear;
|
||
const two = folded.match(/\b(\d{2})\s*$/);
|
||
if (!yMatch && two) {
|
||
const n = Number(two[1]);
|
||
if (n >= 20 && n <= 40) year = 2000 + n;
|
||
}
|
||
return `${year}-${String(month).padStart(2, '0')}`;
|
||
}
|
||
|
||
function cell(v: unknown): string {
|
||
if (v == null) return '';
|
||
if (v instanceof Date && !Number.isNaN(v.getTime())) {
|
||
return v.toISOString().slice(0, 10);
|
||
}
|
||
return String(v).trim();
|
||
}
|
||
|
||
function excelSerialToIso(n: number): string | null {
|
||
if (!Number.isFinite(n) || n < 20000 || n > 80000) return null;
|
||
const utc = new Date(Date.UTC(1899, 11, 30) + Math.round(n) * 86400000);
|
||
if (Number.isNaN(utc.getTime())) return null;
|
||
return utc.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null {
|
||
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
|
||
return raw.toISOString().slice(0, 10);
|
||
}
|
||
if (typeof raw === 'number') return excelSerialToIso(raw);
|
||
const s = cell(raw);
|
||
if (!s) return null;
|
||
const iso = s.match(/(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/);
|
||
if (iso) {
|
||
return `${iso[1]}-${String(iso[2]).padStart(2, '0')}-${String(iso[3]).padStart(2, '0')}`;
|
||
}
|
||
const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](20\d{2})$/);
|
||
if (dmy) {
|
||
return `${dmy[3]}-${String(dmy[2]).padStart(2, '0')}-${String(dmy[1]).padStart(2, '0')}`;
|
||
}
|
||
const dayOnly = s.match(/^(\d{1,2})$/);
|
||
if (dayOnly && ymHint) {
|
||
return `${ymHint}-${String(dayOnly[1]).padStart(2, '0')}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseDayHeader(h: string): number | null {
|
||
const raw = String(h || '').trim();
|
||
// Placeholders: x, X 2, Columna N — not calendar days
|
||
if (!raw || /^x(\s+\d+)?$/i.test(raw) || /^columna/i.test(raw)) return null;
|
||
const m = raw.match(/(\d{1,2})\s*$/);
|
||
if (!m) return null;
|
||
const n = Number(m[1]);
|
||
return n >= 1 && n <= 31 ? n : null;
|
||
}
|
||
|
||
function weekdayHits(row: (string | number | null)[]): number {
|
||
let n = 0;
|
||
for (let col = 1; col < Math.min(row.length, 10); col++) {
|
||
if (
|
||
/LUNES|MARTES|MI[EÉ]RCOLES|JUEVES|VIERNES|S[ÁA]BADO|DOMINGO/i.test(
|
||
cell(row[col])
|
||
)
|
||
) {
|
||
n += 1;
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function looksLikeGrid(rows: SheetRows): boolean {
|
||
let hits = 0;
|
||
for (const row of rows.slice(0, 40)) {
|
||
const line = (row || []).map(cell).join(' ').toUpperCase();
|
||
if (
|
||
line.includes('SEMANA') ||
|
||
/\bSTORIES?\b/.test(line) ||
|
||
/^POST\b/.test(line) ||
|
||
/\bSTORY\b/.test(line)
|
||
) {
|
||
hits += 1;
|
||
}
|
||
if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1;
|
||
}
|
||
return hits >= 2;
|
||
}
|
||
|
||
function colorFor(platform: string, format: string, kind?: string): string {
|
||
const f = `${format} ${kind}`.toLowerCase();
|
||
if (f.includes('stor')) return '#374151';
|
||
if ((platform || '').toLowerCase().includes('linkedin')) return '#7C66B8';
|
||
if (f.includes('reel')) return '#7C66B8';
|
||
return '#2071cf';
|
||
}
|
||
|
||
function normalizePlatform(raw: string, format: string): string {
|
||
const s = `${raw} ${format}`.toLowerCase();
|
||
if (s.includes('linkedin')) return 'linkedin';
|
||
if (s.includes('tiktok')) return 'tiktok';
|
||
if (s.includes('facebook') || s.includes('fb')) return 'facebook';
|
||
if (s.includes('blog')) return 'blog';
|
||
return 'instagram';
|
||
}
|
||
|
||
function kindFromFormat(format: string): string {
|
||
const f = format.toLowerCase();
|
||
if (f.includes('stor')) return 'story';
|
||
if (f.includes('reel')) return 'reel';
|
||
return 'post';
|
||
}
|
||
|
||
/**
|
||
* Official editorial grid (Brava Octubre / BPS):
|
||
* SEMANA N | LUNES d | MARTES d | … | DOMINGO d
|
||
* Story | …content per day…
|
||
* POST | …content per day…
|
||
* Side columns (≥10) are notes/guiones and are ignored.
|
||
* PUBLICADO / CLIENTE status rows are ignored.
|
||
*/
|
||
export function parseWeekGrid(
|
||
rows: SheetRows,
|
||
year: number,
|
||
month: number
|
||
): RrssPiece[] {
|
||
const pieces: RrssPiece[] = [];
|
||
let dayCols: { col: number; day: number }[] = [];
|
||
/** Index in dayCols where days wrap into the next month (e.g. 28…1). */
|
||
let wrapIdx: number | null = null;
|
||
let kind: 'STORIES' | 'POST' | null = null;
|
||
|
||
for (const row of rows) {
|
||
if (!row || !row.some((c) => cell(c))) continue;
|
||
const c0 = cell(row[0]).toUpperCase();
|
||
|
||
const isStatus =
|
||
c0.startsWith('PUBLICADO') ||
|
||
c0.startsWith('CLIENTE') ||
|
||
c0.startsWith('GUION') ||
|
||
c0.startsWith('APROB');
|
||
if (isStatus) continue;
|
||
|
||
const isContentRow =
|
||
c0.startsWith('STORIES') ||
|
||
c0 === 'STORY' ||
|
||
c0 === 'STORIE' ||
|
||
c0.startsWith('POST') ||
|
||
c0.startsWith('REEL') ||
|
||
c0.startsWith('FEED');
|
||
|
||
const isWeekHeader =
|
||
!isContentRow &&
|
||
(c0.startsWith('SEMANA') ||
|
||
weekdayHits(row) >= 2 ||
|
||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1])));
|
||
|
||
if (isWeekHeader) {
|
||
dayCols = [];
|
||
for (let col = 1; col < Math.min(row.length, 10); col++) {
|
||
const day = parseDayHeader(cell(row[col]));
|
||
if (day) dayCols.push({ col, day });
|
||
}
|
||
wrapIdx = null;
|
||
for (let i = 1; i < dayCols.length; i++) {
|
||
if (dayCols[i].day < dayCols[i - 1].day && dayCols[i - 1].day >= 20) {
|
||
wrapIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
kind = null;
|
||
continue;
|
||
}
|
||
|
||
if (c0.startsWith('STORIES') || c0 === 'STORY' || c0 === 'STORIE') {
|
||
kind = 'STORIES';
|
||
} else if (c0.startsWith('POST') || c0.startsWith('REEL') || c0.startsWith('FEED')) {
|
||
kind = 'POST';
|
||
} else if (!kind) {
|
||
continue;
|
||
}
|
||
|
||
for (let i = 0; i < dayCols.length; i++) {
|
||
const { col, day } = dayCols[i];
|
||
const text = cell(row[col]);
|
||
if (!text || /^x(\s+\d+)?$/i.test(text) || /^columna/i.test(text)) continue;
|
||
|
||
let y = year;
|
||
let m = month;
|
||
if (wrapIdx != null && i >= wrapIdx) {
|
||
m = month + 1;
|
||
if (m > 12) {
|
||
m = 1;
|
||
y = year + 1;
|
||
}
|
||
}
|
||
const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
const dt = new Date(`${date}T12:00:00Z`);
|
||
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
|
||
|
||
const format =
|
||
kind === 'STORIES'
|
||
? 'Stories'
|
||
: /\breel\b/i.test(text)
|
||
? 'Reel'
|
||
: /carrusel|carousel/i.test(text)
|
||
? 'Carrusel'
|
||
: 'Post';
|
||
const k = kind === 'STORIES' ? 'story' : kindFromFormat(format);
|
||
pieces.push({
|
||
id: `${date}-${k}-${slugify(text).slice(0, 28)}`,
|
||
date,
|
||
platform: 'instagram',
|
||
format,
|
||
pillar: k === 'story' ? 'stories' : 'feed',
|
||
title: text.split('\n')[0].slice(0, 140),
|
||
hook: text.split('\n')[0].slice(0, 180),
|
||
body: text,
|
||
status: 'planned',
|
||
kind: k,
|
||
color: colorFor('instagram', format, k),
|
||
});
|
||
}
|
||
}
|
||
|
||
const seen = new Set<string>();
|
||
return pieces.filter((p) => {
|
||
if (seen.has(p.id)) return false;
|
||
seen.add(p.id);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function headerIndex(headers: string[]): Record<string, number> {
|
||
const map: Record<string, number> = {};
|
||
const aliases: Record<string, string[]> = {
|
||
date: ['fecha', 'date', 'dia', 'día', 'day'],
|
||
platform: ['plataforma', 'platform', 'red', 'canal', 'network'],
|
||
format: ['formato', 'format', 'tipo', 'type'],
|
||
title: ['titulo', 'título', 'title', 'pieza', 'nombre'],
|
||
hook: ['hook', 'gancho'],
|
||
body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'],
|
||
cta: ['cta', 'llamada'],
|
||
hashtags: ['hashtags', 'hashtag', 'tags'],
|
||
pillar: ['pilar', 'pillar', 'linea', 'línea', 'tema'],
|
||
time: ['hora', 'time'],
|
||
objective: ['objetivo', 'objective', 'metric', 'kpi'],
|
||
status: ['estado', 'status'],
|
||
};
|
||
headers.forEach((h, i) => {
|
||
const k = h
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.trim();
|
||
for (const [field, list] of Object.entries(aliases)) {
|
||
if (list.includes(k) && map[field] == null) map[field] = i;
|
||
}
|
||
});
|
||
return map;
|
||
}
|
||
|
||
export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
|
||
let headerRow = -1;
|
||
let idx: Record<string, number> = {};
|
||
for (let i = 0; i < Math.min(rows.length, 12); i++) {
|
||
const headers = (rows[i] || []).map(cell);
|
||
const found = headerIndex(headers);
|
||
if (found.date != null && (found.title != null || found.body != null || found.format != null)) {
|
||
headerRow = i;
|
||
idx = found;
|
||
break;
|
||
}
|
||
}
|
||
if (headerRow < 0) return [];
|
||
|
||
const pieces: RrssPiece[] = [];
|
||
for (const row of rows.slice(headerRow + 1)) {
|
||
if (!row) continue;
|
||
const get = (k: string) => (idx[k] != null ? cell(row[idx[k]]) : '');
|
||
const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint);
|
||
if (!date) continue;
|
||
const format = get('format') || 'Post';
|
||
const title = get('title') || get('body') || get('hook') || format;
|
||
if (!title.trim()) continue;
|
||
const platform = normalizePlatform(get('platform'), format);
|
||
const kind = kindFromFormat(format);
|
||
const statusRaw = get('status').toLowerCase();
|
||
const status = /publi|subido|posted/.test(statusRaw)
|
||
? 'published'
|
||
: /aprob/.test(statusRaw)
|
||
? 'approved'
|
||
: 'planned';
|
||
pieces.push({
|
||
id: `${date}-${kind}-${slugify(title).slice(0, 28)}`,
|
||
date,
|
||
time: get('time') || undefined,
|
||
platform,
|
||
format,
|
||
pillar: get('pillar') || undefined,
|
||
title: title.split('\n')[0].slice(0, 160),
|
||
hook: get('hook') || title.split('\n')[0],
|
||
body: get('body') || title,
|
||
cta: get('cta') || undefined,
|
||
hashtags: get('hashtags') || undefined,
|
||
objective_metric: get('objective') || undefined,
|
||
status,
|
||
kind,
|
||
color: colorFor(platform, format, kind),
|
||
});
|
||
}
|
||
return pieces;
|
||
}
|
||
|
||
export function buildPlan(
|
||
ym: string,
|
||
pieces: RrssPiece[],
|
||
tenantLabel: string
|
||
): RrssMonthPlan {
|
||
const sorted = [...pieces].sort((a, b) => a.date.localeCompare(b.date));
|
||
const posts = sorted.filter((p) => p.kind !== 'story').length;
|
||
const stories = sorted.filter((p) => p.kind === 'story').length;
|
||
const ig = sorted.filter((p) => (p.platform || '').toLowerCase().includes('insta')).length;
|
||
const li = sorted.filter((p) => (p.platform || '').toLowerCase().includes('linkedin')).length;
|
||
return {
|
||
kind: 'rrss_month',
|
||
month: ym,
|
||
label: monthLabel(ym),
|
||
hero: {
|
||
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
|
||
title: `Calendario editorial · ${monthLabel(ym)}`,
|
||
lede: 'Importado desde Excel (rejilla oficial SEMANA / Story / POST) o CSV. Cada pieza queda en su día del mes.',
|
||
chips: [
|
||
`${sorted.length} piezas`,
|
||
posts ? `${posts} posts` : '',
|
||
stories ? `${stories} stories` : '',
|
||
ig ? `${ig} IG` : '',
|
||
li ? `${li} LinkedIn` : '',
|
||
].filter(Boolean),
|
||
},
|
||
pieces: sorted,
|
||
};
|
||
}
|
||
|
||
function asPlan(raw: unknown): RrssMonthPlan | null {
|
||
if (!raw || typeof raw !== 'object') return null;
|
||
const o = raw as Record<string, unknown>;
|
||
if (o.kind === 'rrss_month' && Array.isArray(o.pieces) && typeof o.month === 'string') {
|
||
return o as unknown as RrssMonthPlan;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function parseJsonCalendar(text: string, tenantLabel: string): ImportResult | null {
|
||
let raw: unknown;
|
||
try {
|
||
raw = JSON.parse(text);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const warnings: string[] = [];
|
||
const plans: RrssMonthPlan[] = [];
|
||
const single = asPlan(raw);
|
||
if (single) {
|
||
plans.push(single);
|
||
return {
|
||
plans,
|
||
warnings,
|
||
sheets: [{ name: 'json', pieces: single.pieces.length, mode: 'json' }],
|
||
};
|
||
}
|
||
if (Array.isArray(raw)) {
|
||
if (raw.length && asPlan(raw[0])) {
|
||
for (const item of raw) {
|
||
const p = asPlan(item);
|
||
if (p) plans.push(p);
|
||
}
|
||
return {
|
||
plans,
|
||
warnings,
|
||
sheets: [
|
||
{
|
||
name: 'json',
|
||
pieces: plans.reduce((a, p) => a + p.pieces.length, 0),
|
||
mode: 'json',
|
||
},
|
||
],
|
||
};
|
||
}
|
||
const pieces = raw as RrssPiece[];
|
||
if (pieces[0] && typeof pieces[0] === 'object' && 'date' in pieces[0]) {
|
||
const by = new Map<string, RrssPiece[]>();
|
||
for (const p of pieces) {
|
||
const d = String(p.date || '').slice(0, 10);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) continue;
|
||
const ym = d.slice(0, 7);
|
||
const list = by.get(ym) || [];
|
||
list.push(p);
|
||
by.set(ym, list);
|
||
}
|
||
for (const [ym, list] of by) plans.push(buildPlan(ym, list, tenantLabel));
|
||
return {
|
||
plans,
|
||
warnings,
|
||
sheets: [{ name: 'json-pieces', pieces: pieces.length, mode: 'json' }],
|
||
};
|
||
}
|
||
}
|
||
return { plans, warnings: ['JSON no reconocido como calendario RRSS'], sheets: [] };
|
||
}
|
||
|
||
export function sheetsToPlans(
|
||
sheets: { name: string; rows: SheetRows }[],
|
||
tenantLabel: string,
|
||
fallbackYear = new Date().getFullYear()
|
||
): ImportResult {
|
||
const warnings: string[] = [];
|
||
const byMonth = new Map<string, RrssPiece[]>();
|
||
const report: ImportResult['sheets'] = [];
|
||
|
||
for (const sh of sheets) {
|
||
if (!sh.rows?.length) continue;
|
||
const ym = inferYmFromSheetName(sh.name, fallbackYear);
|
||
let mode = 'empty';
|
||
let pieces: RrssPiece[] = [];
|
||
if (looksLikeGrid(sh.rows) && ym) {
|
||
const [y, m] = ym.split('-').map(Number);
|
||
pieces = parseWeekGrid(sh.rows, y, m);
|
||
mode = 'grid';
|
||
}
|
||
if (!pieces.length) {
|
||
const tabular = parseTabular(sh.rows, ym || undefined);
|
||
if (tabular.length) {
|
||
pieces = tabular;
|
||
mode = 'tabla';
|
||
}
|
||
}
|
||
if (!pieces.length && looksLikeGrid(sh.rows) && !ym) {
|
||
warnings.push(
|
||
`Hoja «${sh.name}»: parece calendario semanal pero no se pudo leer el mes. Renómbrala (ej. Octubre 2026).`
|
||
);
|
||
}
|
||
report.push({ name: sh.name, pieces: pieces.length, mode });
|
||
for (const p of pieces) {
|
||
const key = p.date.slice(0, 7);
|
||
const list = byMonth.get(key) || [];
|
||
list.push(p);
|
||
byMonth.set(key, list);
|
||
}
|
||
}
|
||
|
||
const plans = [...byMonth.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([ym, pieces]) => {
|
||
const seen = new Set<string>();
|
||
const uniq = pieces.filter((p) => {
|
||
if (seen.has(p.id)) return false;
|
||
seen.add(p.id);
|
||
return true;
|
||
});
|
||
return buildPlan(ym, uniq, tenantLabel);
|
||
});
|
||
|
||
if (!plans.length) {
|
||
warnings.push(
|
||
'No se encontraron piezas. Usa columnas fecha + título (o copy), o una rejilla SEMANA / STORIES / POST como Blue Parrot.'
|
||
);
|
||
}
|
||
return { plans, warnings, sheets: report };
|
||
}
|
||
|
||
/** Fallback tabular CSV. Preferred official format = Excel week grid like Brava Octubre. */
|
||
export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo
|
||
2026-10-01,instagram,Stories,H1 · ENCUESTA Otoño: ¿qué le pides a tu piel?,Encuesta otoño,Sticker encuesta,Responde a la story,,stories,09:00,Respuestas
|
||
2026-10-03,instagram,Reel,15_REEL TREND · Cosas que nunca escucharás en Brava,Trend propio,Copy del reel,Guarda este post,#Brava,feed,10:30,Guardados
|
||
2026-10-05,instagram,Carrusel,POST 1 · ¿Lista para un peeling?,Señales sí / aún no,Copy del carrusel,Pedir cita,,feed,10:30,Citas
|
||
`;
|
||
|
||
/** Minimal October-style grid as TSV (for docs / paste into Excel). */
|
||
export const SAMPLE_GRID_TSV = `SEMANA 1\tx\tx\tx\tJUEVES 1\tVIERNES 2\tSÁBADO 3\tDOMINGO 4
|
||
Story\t\t\t\tH1 · ENCUESTA\\nOtoño: ¿qué le pides a tu piel?\tH2 · V/F\\n¿Octubre ya es mes de peeling?\t\t
|
||
POST\t\t\t\t\t\t15_REEL TREND · Ejemplo reel\t
|
||
SEMANA 2\tLUNES 5\tMARTES 6\tMIERCOLES 7\tJUEVES 8\tVIERNES 9\tSÁBADO 10\tDOMINGO 11
|
||
Story\tH4 · ENCUESTA\t\tH5 · CAJÓN\t\tH6 · V/F\tH7 · BTS\t
|
||
POST\tPOST 1 · Título del carrusel · PLANT 4\t\tReel TREND: ejemplo\tPOST 2 · Debate · PLANT 2\t\t\t
|
||
`;
|