2680 lines
96 KiB
JavaScript
2680 lines
96 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const UNIT_PRICE = 500000;
|
||
|
||
const REUSE = {
|
||
new_block: 1,
|
||
no_change: 0.10,
|
||
minor: 0.25,
|
||
medium: 0.40,
|
||
major: 0.70
|
||
};
|
||
|
||
const REUSE_ONLY = {
|
||
no_change: 0.10,
|
||
minor: 0.25,
|
||
medium: 0.40,
|
||
major: 0.70
|
||
};
|
||
|
||
const CATALOG = {
|
||
general: {
|
||
label: 'عمومی',
|
||
hasComplexity: true,
|
||
blocks: {
|
||
Navbar: { simple: 1, medium: 1.5, complex: 2 },
|
||
Sidebar: { simple: 1, medium: 2, complex: 3 },
|
||
Footer: { simple: 0.8, medium: 1, complex: 1.5 },
|
||
Hero: { simple: 2, medium: 3, complex: 4 },
|
||
Banner: { simple: 1, medium: 1.5, complex: 2 },
|
||
CTA: { simple: 0.8, medium: 1, complex: 1.5 },
|
||
'Feature Grid': { simple: 1, medium: 2, complex: 3 },
|
||
'Card Section': { simple: 1, medium: 2, complex: 3 },
|
||
FAQ: { simple: 1, medium: 1.5, complex: 2 },
|
||
Timeline: { simple: 1, medium: 2, complex: 3 },
|
||
Testimonial: { simple: 1, medium: 2, complex: 3 },
|
||
Pricing: { simple: 2, medium: 3, complex: 4 },
|
||
Gallery: { simple: 2, medium: 3, complex: 5 },
|
||
'Blog List': { simple: 2, medium: 3, complex: 4 },
|
||
'Contact Section': { simple: 1, medium: 2, complex: 3 }
|
||
}
|
||
},
|
||
dashboard: {
|
||
label: 'داشبورد',
|
||
hasComplexity: true,
|
||
blocks: {
|
||
'Stat Card': { simple: 0.5, medium: 0.8, complex: 1 },
|
||
Widget: { simple: 1, medium: 2, complex: 3 },
|
||
'Data Table': { simple: 2, medium: 4, complex: 6 },
|
||
'Filter Bar': { simple: 1, medium: 2, complex: 3 },
|
||
Search: { simple: 0.5, medium: 1, complex: 1.5 },
|
||
Pagination: { simple: 0.5, medium: 0.8, complex: 1 },
|
||
Tabs: { simple: 0.8, medium: 1, complex: 2 },
|
||
Chart: { simple: 2, medium: 3, complex: 5 },
|
||
Calendar: { simple: 2, medium: 4, complex: 6 },
|
||
Kanban: { simple: 4, medium: 6, complex: 8 }
|
||
}
|
||
},
|
||
form: {
|
||
label: 'فرم',
|
||
hasComplexity: true,
|
||
blocks: {
|
||
'Form Section': { simple: 1, medium: 2, complex: 3 },
|
||
'Input Group': { simple: 0.3, medium: 0.5, complex: 1 },
|
||
'File Upload': { simple: 1, medium: 2, complex: 3 },
|
||
'Rich Text': { simple: 2, medium: 3, complex: 4 },
|
||
'Multi Step': { simple: 3, medium: 5, complex: 8 },
|
||
'Dynamic Field': { simple: 2, medium: 4, complex: 6 }
|
||
}
|
||
},
|
||
interactive: {
|
||
label: 'تعاملی',
|
||
hasComplexity: false,
|
||
blocks: {
|
||
Modal: 1,
|
||
Drawer: 1,
|
||
Dropdown: 0.5,
|
||
Popover: 0.5,
|
||
'Command Menu': 1,
|
||
Toast: 0.5,
|
||
Skeleton: 0.5,
|
||
'Loading State': 0.5,
|
||
'Empty State': 0.5,
|
||
'Error State': 0.5
|
||
}
|
||
}
|
||
};
|
||
|
||
const COMPLEXITY_LABELS = { simple: 'Simple', medium: 'Medium', complex: 'Complex' };
|
||
const REUSE_LABELS = {
|
||
new_block: 'جدید (۱۰۰٪)',
|
||
no_change: 'Reuse بدون تغییر (۱۰٪)',
|
||
minor: 'تغییر جزئی (۲۵٪)',
|
||
medium: 'تغییر متوسط (۴۰٪)',
|
||
major: 'تغییر زیاد (۷۰٪)'
|
||
};
|
||
const REUSE_ONLY_LABELS = {
|
||
no_change: 'بدون تغییر (۱۰٪)',
|
||
minor: 'تغییر جزئی (۲۵٪)',
|
||
medium: 'تغییر متوسط (۴۰٪)',
|
||
major: 'تغییر زیاد (۷۰٪)'
|
||
};
|
||
const CAT_LABELS = { general: 'عمومی', dashboard: 'داشبورد', form: 'فرم', interactive: 'تعاملی' };
|
||
|
||
const DEFAULT_ROWS = [];
|
||
|
||
const LAYOUT_BLOCKS = { Navbar: true, Footer: true };
|
||
|
||
const BLOCK_SIMILARITY_GROUPS = [
|
||
['Hero', 'Banner', 'CTA'],
|
||
['Feature Grid', 'Card Section', 'Gallery', 'Pricing'],
|
||
['Testimonial', 'FAQ', 'Timeline'],
|
||
['Blog List', 'Card Section', 'Feature Grid', 'Gallery'],
|
||
['Contact Section', 'Form Section', 'CTA', 'Banner'],
|
||
['Navbar', 'Sidebar'],
|
||
['Stat Card', 'Widget', 'Chart'],
|
||
['Data Table', 'Filter Bar', 'Search', 'Pagination', 'Tabs'],
|
||
['Modal', 'Drawer', 'Popover', 'Dropdown', 'Toast', 'Command Menu'],
|
||
['Form Section', 'Input Group', 'File Upload', 'Multi Step', 'Dynamic Field']
|
||
];
|
||
|
||
const REUSE_MATCH_THRESHOLD = 18;
|
||
|
||
/** @type {Record<string, { rows: object[], pageTitle: string, meta: object, analyzed?: object[] }>} */
|
||
var urlAnalysisCache = Object.create(null);
|
||
|
||
let pageIdCounter = 0;
|
||
let rowIdCounter = 0;
|
||
let rowsEl, emptyEl, tabsEl, pagesListEl, archiveListEl, calcMainEl;
|
||
let activePageId = null;
|
||
let isRendering = false;
|
||
|
||
const state = {
|
||
pages: [],
|
||
reportEdited: false
|
||
};
|
||
|
||
function escapeHtml(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function getReportBodyEl() {
|
||
return document.getElementById('calc-project-report-body');
|
||
}
|
||
|
||
function getProjectTitle() {
|
||
var el = document.getElementById('opt-project-title');
|
||
return el ? el.value.trim() : '';
|
||
}
|
||
|
||
function buildReportHeading() {
|
||
var title = getProjectTitle();
|
||
if (title) return 'گزارش برآورد پروژه — ' + title;
|
||
return 'گزارش برآورد پروژه';
|
||
}
|
||
|
||
function sanitizeFilename(name) {
|
||
return String(name)
|
||
.replace(/[^\w\u0600-\u06FF\-]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 60) || 'project';
|
||
}
|
||
|
||
function getReportDownloadBasename() {
|
||
var title = getProjectTitle();
|
||
var stamp = new Date().toISOString().slice(0, 10);
|
||
if (title) return 'project-report-' + sanitizeFilename(title) + '-' + stamp;
|
||
return 'project-report-' + stamp;
|
||
}
|
||
|
||
function saveProjectTitleToStorage() {
|
||
try {
|
||
localStorage.setItem('feep-price-project-title', getProjectTitle());
|
||
} catch (e) { /* ignore quota */ }
|
||
}
|
||
|
||
function loadProjectTitleFromStorage() {
|
||
var el = document.getElementById('opt-project-title');
|
||
if (!el) return;
|
||
try {
|
||
var saved = localStorage.getItem('feep-price-project-title');
|
||
if (saved != null) el.value = saved;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function nextPageId() {
|
||
pageIdCounter += 1;
|
||
return pageIdCounter;
|
||
}
|
||
|
||
function nextRowId() {
|
||
rowIdCounter += 1;
|
||
return rowIdCounter;
|
||
}
|
||
|
||
function fmt(n) {
|
||
return Math.round(n).toLocaleString('fa-IR');
|
||
}
|
||
|
||
function fmtUnit(n) {
|
||
const s = n % 1 === 0 ? n.toFixed(0) : n.toFixed(1);
|
||
return parseFloat(s).toLocaleString('fa-IR', { maximumFractionDigits: 1 });
|
||
}
|
||
|
||
function getBlockUnit(catKey, blockName, complexity) {
|
||
const cat = CATALOG[catKey];
|
||
if (!cat) return 0;
|
||
const block = cat.blocks[blockName];
|
||
if (block == null) return 0;
|
||
if (!cat.hasComplexity) return block;
|
||
return block[complexity] ?? 0;
|
||
}
|
||
|
||
function buildOptions(obj, labels) {
|
||
return Object.keys(obj).map(function (k) {
|
||
return '<option value="' + k + '">' + (labels ? labels[k] : k) + '</option>';
|
||
}).join('');
|
||
}
|
||
|
||
function getActivePage() {
|
||
return state.pages.find(function (p) { return p.id === activePageId; });
|
||
}
|
||
|
||
function getPageIndex(pageId) {
|
||
return state.pages.findIndex(function (p) { return p.id === pageId; });
|
||
}
|
||
|
||
function createPageData(name, rows) {
|
||
return {
|
||
id: nextPageId(),
|
||
name: name,
|
||
archived: false,
|
||
rows: (rows || []).map(function (r) {
|
||
return Object.assign({ id: nextRowId() }, r);
|
||
}),
|
||
animation: '0',
|
||
animUnits: 2,
|
||
pageMultiplier: 1
|
||
};
|
||
}
|
||
|
||
function parsePageMultiplier(val) {
|
||
var n = parseInt(val, 10);
|
||
if (!n || n < 1) return 1;
|
||
return Math.min(n, 999);
|
||
}
|
||
|
||
function getPageMultiplier(page) {
|
||
if (!page) return 1;
|
||
return parsePageMultiplier(page.pageMultiplier);
|
||
}
|
||
|
||
function getDraftPages() {
|
||
return state.pages.filter(function (p) { return !p.archived; });
|
||
}
|
||
|
||
function getArchivedPages() {
|
||
return state.pages.filter(function (p) { return p.archived; });
|
||
}
|
||
|
||
function isPageReadOnly(page) {
|
||
return page && page.archived;
|
||
}
|
||
|
||
function getNextDraftPageName() {
|
||
var n = state.pages.length + 1;
|
||
return n === 1 ? 'Home' : 'صفحه ' + n;
|
||
}
|
||
|
||
function rowDataFromDom(rowEl) {
|
||
var data = {
|
||
id: parseInt(rowEl.dataset.id, 10),
|
||
mode: rowEl.dataset.mode || 'new',
|
||
cat: rowEl.querySelector('.sel-cat').value,
|
||
block: rowEl.querySelector('.sel-block').value,
|
||
complexity: rowEl.querySelector('.sel-complexity').value,
|
||
reuse: rowEl.querySelector('.sel-reuse').value
|
||
};
|
||
if (data.mode === 'reuse') {
|
||
data.sourcePageId = parseInt(rowEl.dataset.sourcePageId, 10);
|
||
data.sourceRowId = parseInt(rowEl.dataset.sourceRowId, 10);
|
||
if (rowEl.dataset.reuseMatchType) data.reuseMatchType = rowEl.dataset.reuseMatchType;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function saveActivePageFromDom() {
|
||
var page = getActivePage();
|
||
if (!page || !rowsEl) return;
|
||
|
||
if (!page.archived) {
|
||
page.name = document.getElementById('calc-page-name').value.trim() || page.name;
|
||
page.animation = document.getElementById('opt-animation').value;
|
||
page.animUnits = parseFloat(document.getElementById('opt-anim-units').value) || 2;
|
||
}
|
||
var multEl = document.getElementById('opt-page-multiplier');
|
||
if (multEl) page.pageMultiplier = parsePageMultiplier(multEl.value);
|
||
page.rows = Array.from(rowsEl.querySelectorAll('.calc-row')).map(rowDataFromDom);
|
||
sanitizePageLayoutRows(page);
|
||
}
|
||
|
||
function getAnimUnitsForPage(page) {
|
||
if (page.animation === 'complex') {
|
||
return Math.min(3, Math.max(1, page.animUnits || 2));
|
||
}
|
||
return parseFloat(page.animation) || 0;
|
||
}
|
||
|
||
function calcRowUnits(rowData) {
|
||
var base = getBlockUnit(rowData.cat, rowData.block, rowData.complexity);
|
||
var reuseKey = rowData.mode === 'reuse' ? rowData.reuse : rowData.reuse;
|
||
var factor = REUSE[reuseKey] != null ? REUSE[reuseKey] : 1;
|
||
return base * factor;
|
||
}
|
||
|
||
function calcPageTotals(page) {
|
||
var blockUnits = 0;
|
||
getRowsForPage(page).forEach(function (row) {
|
||
blockUnits += calcRowUnits(row);
|
||
});
|
||
var animUnits = getAnimUnitsForPage(page);
|
||
var baseTotalUnits = blockUnits + animUnits;
|
||
var mult = getPageMultiplier(page);
|
||
var totalUnits = baseTotalUnits * mult;
|
||
return {
|
||
blockUnits: blockUnits * mult,
|
||
animUnits: animUnits * mult,
|
||
totalUnits: totalUnits,
|
||
cost: totalUnits * UNIT_PRICE,
|
||
pageMultiplier: mult,
|
||
baseTotalUnits: baseTotalUnits
|
||
};
|
||
}
|
||
|
||
function getBlockRegistry() {
|
||
var registry = [];
|
||
var seen = Object.create(null);
|
||
getArchivedPages().forEach(function (page) {
|
||
page.rows.forEach(function (row) {
|
||
if (row.mode === 'reuse') return;
|
||
if (row.reuse !== 'new_block') return;
|
||
var key = row.cat + '::' + row.block;
|
||
if (seen[key]) return;
|
||
seen[key] = true;
|
||
registry.push({
|
||
sourcePageId: page.id,
|
||
sourceRowId: row.id,
|
||
pageName: page.name,
|
||
cat: row.cat,
|
||
block: row.block,
|
||
complexity: row.complexity,
|
||
label: row.label || row.block,
|
||
fingerprint: row.fingerprint || '',
|
||
key: key
|
||
});
|
||
});
|
||
});
|
||
return registry;
|
||
}
|
||
|
||
function getSequentialPageNumber() {
|
||
return getArchivedPages().length + 1;
|
||
}
|
||
|
||
function normalizeImportUrl(url) {
|
||
try {
|
||
var parsed = new URL(url);
|
||
var host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
|
||
var path = parsed.pathname.replace(/\/+$/, '') || '/';
|
||
return parsed.protocol + '//' + host + path + (parsed.search || '');
|
||
} catch (e) {
|
||
return (url || '').trim().toLowerCase();
|
||
}
|
||
}
|
||
|
||
function findPageBySourceUrl(url) {
|
||
var key = normalizeImportUrl(url);
|
||
return state.pages.find(function (p) {
|
||
return p.sourceUrl && normalizeImportUrl(p.sourceUrl) === key;
|
||
}) || null;
|
||
}
|
||
|
||
function getCachedUrlAnalysis(url) {
|
||
return urlAnalysisCache[normalizeImportUrl(url)] || null;
|
||
}
|
||
|
||
function cacheUrlAnalysis(url, parsed) {
|
||
urlAnalysisCache[normalizeImportUrl(url)] = {
|
||
rows: parsed.rows.map(function (r) { return Object.assign({}, r); }),
|
||
pageTitle: parsed.pageTitle,
|
||
meta: Object.assign({}, parsed.meta || {}),
|
||
analyzed: parsed.analyzed ? parsed.analyzed.map(function (a) { return Object.assign({}, a); }) : undefined
|
||
};
|
||
}
|
||
|
||
function resolveUrlAnalysis(url, html) {
|
||
var cached = getCachedUrlAnalysis(url);
|
||
if (cached) {
|
||
return {
|
||
rows: cached.rows.map(function (r) { return Object.assign({}, r); }),
|
||
pageTitle: cached.pageTitle,
|
||
meta: Object.assign({}, cached.meta),
|
||
analyzed: cached.analyzed ? cached.analyzed.map(function (a) { return Object.assign({}, a); }) : undefined
|
||
};
|
||
}
|
||
var parsed = parseBlocksFromHtml(html);
|
||
cacheUrlAnalysis(url, parsed);
|
||
return parsed;
|
||
}
|
||
|
||
function isLayoutBlock(blockName) {
|
||
return !!LAYOUT_BLOCKS[blockName];
|
||
}
|
||
|
||
function getProjectPageNumber(page) {
|
||
if (!page) return 1;
|
||
if (page.projectPageNumber) return page.projectPageNumber;
|
||
var archived = getArchivedPages();
|
||
var idx = archived.findIndex(function (p) { return p.id === page.id; });
|
||
if (idx >= 0) return idx + 1;
|
||
return archived.length + 1;
|
||
}
|
||
|
||
function shouldExcludeLayoutForPage(page) {
|
||
return getProjectPageNumber(page) > 1;
|
||
}
|
||
|
||
function filterLayoutRows(rows) {
|
||
return rows.filter(function (row) {
|
||
return !isLayoutBlock(row.block);
|
||
});
|
||
}
|
||
|
||
function getRowsForPage(page) {
|
||
if (!page || !page.rows) return [];
|
||
if (!shouldExcludeLayoutForPage(page)) return page.rows;
|
||
return filterLayoutRows(page.rows);
|
||
}
|
||
|
||
function sanitizePageLayoutRows(page) {
|
||
if (!page || !page.rows) return;
|
||
if (!shouldExcludeLayoutForPage(page)) return;
|
||
page.rows = filterLayoutRows(page.rows);
|
||
}
|
||
|
||
function getReuseRegistryForPage(page) {
|
||
var registry = getBlockRegistry();
|
||
if (!shouldExcludeLayoutForPage(page)) return registry;
|
||
return registry.filter(function (r) { return !isLayoutBlock(r.block); });
|
||
}
|
||
|
||
function getBlockSimilarityGroup(blockName) {
|
||
for (var i = 0; i < BLOCK_SIMILARITY_GROUPS.length; i++) {
|
||
if (BLOCK_SIMILARITY_GROUPS[i].indexOf(blockName) !== -1) {
|
||
return BLOCK_SIMILARITY_GROUPS[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function blocksAreSimilar(blockA, blockB) {
|
||
if (!blockA || !blockB) return false;
|
||
if (blockA === blockB) return true;
|
||
var group = getBlockSimilarityGroup(blockA);
|
||
return group ? group.indexOf(blockB) !== -1 : false;
|
||
}
|
||
|
||
function tokenizeForSimilarity(str) {
|
||
return (str || '').toLowerCase()
|
||
.replace(/[^\u0600-\u06FFa-z0-9\s]/g, ' ')
|
||
.split(/\s+/)
|
||
.filter(function (t) { return t.length > 2; });
|
||
}
|
||
|
||
function textSimilarity(a, b) {
|
||
var tokensA = tokenizeForSimilarity(a);
|
||
var tokensB = tokenizeForSimilarity(b);
|
||
if (!tokensA.length || !tokensB.length) return 0;
|
||
var overlap = 0;
|
||
tokensA.forEach(function (t) {
|
||
if (tokensB.indexOf(t) !== -1) overlap += 1;
|
||
});
|
||
return overlap / Math.max(tokensA.length, tokensB.length);
|
||
}
|
||
|
||
function fingerprintSimilarity(a, b) {
|
||
if (!a || !b) return 0;
|
||
if (a === b) return 1;
|
||
var partsA = a.split('|').filter(Boolean);
|
||
var partsB = b.split('|').filter(Boolean);
|
||
if (!partsA.length || !partsB.length) return textSimilarity(a, b);
|
||
var overlap = 0;
|
||
partsA.forEach(function (p) {
|
||
if (partsB.indexOf(p) !== -1) overlap += 1;
|
||
});
|
||
return overlap / Math.max(partsA.length, partsB.length);
|
||
}
|
||
|
||
function scoreReuseMatch(candidate, cat, block, meta) {
|
||
var score = 0;
|
||
var matchType = 'similar';
|
||
|
||
if (candidate.key === cat + '::' + block) {
|
||
score += 100;
|
||
matchType = 'exact';
|
||
}
|
||
if (blocksAreSimilar(block, candidate.block)) {
|
||
score += 45;
|
||
if (matchType !== 'exact') matchType = 'similar-type';
|
||
}
|
||
if (candidate.cat === cat) {
|
||
score += 22;
|
||
if (score < 45) matchType = 'same-category';
|
||
}
|
||
if (meta && meta.fingerprint && candidate.fingerprint) {
|
||
var fpScore = fingerprintSimilarity(meta.fingerprint, candidate.fingerprint);
|
||
if (fpScore >= 0.4) {
|
||
score += Math.round(fpScore * 35);
|
||
if (matchType === 'similar') matchType = 'fingerprint';
|
||
}
|
||
}
|
||
if (meta && meta.label && candidate.label) {
|
||
var labelScore = textSimilarity(meta.label, candidate.label);
|
||
if (labelScore >= 0.35) {
|
||
score += Math.round(labelScore * 25);
|
||
}
|
||
}
|
||
|
||
return { score: score, matchType: matchType };
|
||
}
|
||
|
||
function buildRegistryEntry(sourcePageId, sourceRowId, pageName, row) {
|
||
return {
|
||
sourcePageId: sourcePageId,
|
||
sourceRowId: sourceRowId,
|
||
pageName: pageName,
|
||
cat: row.cat,
|
||
block: row.block,
|
||
complexity: row.complexity,
|
||
label: row.label || row.block,
|
||
fingerprint: row.fingerprint || '',
|
||
key: row.cat + '::' + row.block
|
||
};
|
||
}
|
||
|
||
function findReuseInRegistry(registry, cat, block, meta) {
|
||
if (!registry.length) return null;
|
||
|
||
var best = null;
|
||
var bestScore = REUSE_MATCH_THRESHOLD;
|
||
var bestType = 'similar';
|
||
|
||
registry.forEach(function (candidate) {
|
||
var scored = scoreReuseMatch(candidate, cat, block, meta || {});
|
||
if (scored.score > bestScore) {
|
||
bestScore = scored.score;
|
||
best = candidate;
|
||
bestType = scored.matchType;
|
||
} else if (scored.score === bestScore && best) {
|
||
if (candidate.sourcePageId < best.sourcePageId ||
|
||
(candidate.sourcePageId === best.sourcePageId && candidate.sourceRowId < best.sourceRowId)) {
|
||
best = candidate;
|
||
bestType = scored.matchType;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!best) return null;
|
||
return { source: best, matchType: bestType, score: bestScore };
|
||
}
|
||
|
||
function findIntraPageReuse(localRegistry, cat, block, meta) {
|
||
if (!localRegistry.length) return null;
|
||
|
||
for (var i = 0; i < localRegistry.length; i++) {
|
||
var candidate = localRegistry[i];
|
||
if (candidate.cat === cat && candidate.block === block) {
|
||
return { source: candidate, matchType: 'exact', score: 100 };
|
||
}
|
||
}
|
||
|
||
if (meta && meta.fingerprint) {
|
||
for (var j = 0; j < localRegistry.length; j++) {
|
||
var fpCandidate = localRegistry[j];
|
||
if (fpCandidate.fingerprint && fpCandidate.fingerprint === meta.fingerprint) {
|
||
return { source: fpCandidate, matchType: 'exact', score: 100 };
|
||
}
|
||
}
|
||
}
|
||
|
||
return findReuseInRegistry(localRegistry, cat, block, meta);
|
||
}
|
||
|
||
function ensureLocalRegistryEntry(localRegistry, resultIndex, row) {
|
||
var key = row.cat + '::' + row.block;
|
||
var hasKey = localRegistry.some(function (c) { return c.key === key; });
|
||
var hasFingerprint = row.fingerprint && localRegistry.some(function (c) {
|
||
return c.fingerprint && c.fingerprint === row.fingerprint;
|
||
});
|
||
if (hasKey || hasFingerprint) return;
|
||
|
||
localRegistry.push(buildRegistryEntry(
|
||
INTRA_PAGE_SOURCE,
|
||
resultIndex,
|
||
'همین صفحه',
|
||
row
|
||
));
|
||
}
|
||
|
||
function findReuseSource(cat, block, meta) {
|
||
return findReuseInRegistry(getBlockRegistry(), cat, block, meta);
|
||
}
|
||
|
||
var INTRA_PAGE_SOURCE = '__same_page__';
|
||
|
||
function resolveIntraPageReuseReferences(page) {
|
||
if (!page || !page.rows) return;
|
||
var indexToId = page.rows.map(function (r) { return r.id; });
|
||
page.rows.forEach(function (row) {
|
||
if (row.sourcePageId !== INTRA_PAGE_SOURCE) return;
|
||
var idx = row.sourceRowId;
|
||
if (typeof idx === 'number' && indexToId[idx] != null) {
|
||
row.sourcePageId = page.id;
|
||
row.sourceRowId = indexToId[idx];
|
||
row.sourcePageName = page.name;
|
||
}
|
||
});
|
||
}
|
||
|
||
function dedupeRowsWithinPage(page) {
|
||
if (!page || !page.rows || page.rows.length < 2) return 0;
|
||
|
||
var localRegistry = [];
|
||
var reuseCount = 0;
|
||
|
||
page.rows.forEach(function (row) {
|
||
var meta = {
|
||
fingerprint: row.fingerprint || '',
|
||
label: row.label || row.block
|
||
};
|
||
|
||
if (row.mode === 'reuse') {
|
||
var key = row.cat + '::' + row.block;
|
||
var hasKey = localRegistry.some(function (c) { return c.key === key; });
|
||
var hasFingerprint = row.fingerprint && localRegistry.some(function (c) {
|
||
return c.fingerprint && c.fingerprint === row.fingerprint;
|
||
});
|
||
if (!hasKey && !hasFingerprint) {
|
||
localRegistry.push(buildRegistryEntry(page.id, row.id, page.name, row));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (row.reuse !== 'new_block') return;
|
||
|
||
var match = findIntraPageReuse(localRegistry, row.cat, row.block, meta);
|
||
|
||
if (match) {
|
||
row.mode = 'reuse';
|
||
row.sourcePageId = page.id;
|
||
row.sourceRowId = match.source.sourceRowId;
|
||
row.sourcePageName = page.name;
|
||
row.reuse = reuseLevelForMatch(match.matchType);
|
||
row.reuseMatchType = match.matchType;
|
||
reuseCount += 1;
|
||
} else {
|
||
localRegistry.push(buildRegistryEntry(page.id, row.id, page.name, row));
|
||
}
|
||
});
|
||
|
||
return reuseCount;
|
||
}
|
||
|
||
function reuseLevelForMatch(matchType) {
|
||
if (matchType === 'exact') return 'no_change';
|
||
if (matchType === 'similar-type' || matchType === 'fingerprint') return 'minor';
|
||
return 'medium';
|
||
}
|
||
|
||
function matchRowsAsNew(rawRows, options) {
|
||
return matchRowsWithReuse(rawRows, Object.assign({}, options || {}, { allowArchiveReuse: false }));
|
||
}
|
||
|
||
function matchRowsWithReuse(rawRows, options) {
|
||
var excludeLayout = options && options.excludeLayout;
|
||
var excludeSourcePageId = options && options.excludeSourcePageId;
|
||
var allowArchiveReuse = !options || options.allowArchiveReuse !== false;
|
||
var result = [];
|
||
var localRegistry = [];
|
||
var stats = { newCount: 0, reuseCount: 0, skippedLayout: 0 };
|
||
|
||
rawRows.forEach(function (row) {
|
||
if (excludeLayout && isLayoutBlock(row.block)) {
|
||
stats.skippedLayout += 1;
|
||
return;
|
||
}
|
||
|
||
var meta = {
|
||
fingerprint: row.fingerprint,
|
||
label: row.label
|
||
};
|
||
|
||
var match = findIntraPageReuse(localRegistry, row.cat, row.block, meta);
|
||
if (!match && allowArchiveReuse) {
|
||
match = findReuseSource(row.cat, row.block, meta);
|
||
if (match && excludeSourcePageId && match.source.sourcePageId === excludeSourcePageId) {
|
||
match = null;
|
||
}
|
||
}
|
||
|
||
if (match) {
|
||
result.push({
|
||
mode: 'reuse',
|
||
sourcePageId: match.source.sourcePageId,
|
||
sourceRowId: match.source.sourceRowId,
|
||
sourcePageName: match.source.pageName,
|
||
cat: row.cat,
|
||
block: row.block,
|
||
complexity: row.complexity || match.source.complexity,
|
||
reuse: reuseLevelForMatch(match.matchType),
|
||
fingerprint: row.fingerprint || '',
|
||
label: row.label || row.block,
|
||
reuseMatchType: match.matchType
|
||
});
|
||
stats.reuseCount += 1;
|
||
ensureLocalRegistryEntry(localRegistry, result.length - 1, row);
|
||
} else {
|
||
result.push({
|
||
mode: 'new',
|
||
cat: row.cat,
|
||
block: row.block,
|
||
complexity: row.complexity,
|
||
reuse: 'new_block',
|
||
fingerprint: row.fingerprint || '',
|
||
label: row.label || row.block
|
||
});
|
||
ensureLocalRegistryEntry(localRegistry, result.length - 1, row);
|
||
stats.newCount += 1;
|
||
}
|
||
});
|
||
|
||
return { rows: result, stats: stats };
|
||
}
|
||
|
||
function getContentPercent() {
|
||
var v = document.getElementById('opt-content').value;
|
||
if (v === '0.05_img') return 0.05;
|
||
return parseFloat(v) || 0;
|
||
}
|
||
|
||
function getRevisionUnits() {
|
||
var el = document.getElementById('opt-revision-units');
|
||
if (!el) return 0;
|
||
var n = parseFloat(el.value);
|
||
return Math.max(0, n || 0);
|
||
}
|
||
|
||
function getSelectLabel(id) {
|
||
var el = document.getElementById(id);
|
||
if (!el || !el.options.length) return '—';
|
||
return el.options[el.selectedIndex].textContent.trim();
|
||
}
|
||
|
||
function getAnimationLabel(page) {
|
||
if (!page) return '—';
|
||
if (page.animation === 'complex') {
|
||
return 'Motion پیچیده — ' + fmtUnit(getAnimUnitsForPage(page)) + ' Unit';
|
||
}
|
||
if (page.animation === '0') return 'Hover Effect — رایگان';
|
||
if (page.animation === '0.2') return 'Transition — ۰.۲ Unit';
|
||
if (page.animation === '0.5') return 'Motion ساده — ۰.۵ Unit';
|
||
return '—';
|
||
}
|
||
|
||
function getReuseStatusLabel(row) {
|
||
if (row.mode === 'reuse') {
|
||
return REUSE_ONLY_LABELS[row.reuse] || row.reuse;
|
||
}
|
||
return REUSE_LABELS[row.reuse] || REUSE_LABELS.new_block;
|
||
}
|
||
|
||
function getProjectFinancials() {
|
||
var projectBlockUnits = 0;
|
||
var projectAnimUnits = 0;
|
||
|
||
state.pages.forEach(function (page) {
|
||
var totals = calcPageTotals(page);
|
||
projectBlockUnits += totals.blockUnits;
|
||
projectAnimUnits += totals.animUnits;
|
||
});
|
||
|
||
var revisionUnits = getRevisionUnits();
|
||
var totalUnits = projectBlockUnits + projectAnimUnits + revisionUnits;
|
||
var blocksCost = totalUnits * UNIT_PRICE;
|
||
|
||
var contentPct = getContentPercent();
|
||
var responsivePct = parseFloat(document.getElementById('opt-responsive').value) || 0;
|
||
var darkPct = parseFloat(document.getElementById('opt-dark').value) || 0;
|
||
var bilingualPct = parseFloat(document.getElementById('opt-bilingual').value) || 0;
|
||
|
||
var revisionCost = revisionUnits * UNIT_PRICE;
|
||
var contentExtra = blocksCost * contentPct;
|
||
var afterContent = blocksCost + contentExtra;
|
||
var responsiveExtra = afterContent * responsivePct;
|
||
var afterResponsive = afterContent + responsiveExtra;
|
||
var darkExtra = afterResponsive * darkPct;
|
||
var afterDark = afterResponsive + darkExtra;
|
||
var bilingualExtra = afterDark * bilingualPct;
|
||
var total = afterDark + bilingualExtra;
|
||
|
||
return {
|
||
projectBlockUnits: projectBlockUnits,
|
||
projectAnimUnits: projectAnimUnits,
|
||
revisionUnits: revisionUnits,
|
||
totalUnits: totalUnits,
|
||
blocksCost: blocksCost,
|
||
contentExtra: contentExtra,
|
||
responsiveExtra: responsiveExtra,
|
||
darkExtra: darkExtra,
|
||
bilingualExtra: bilingualExtra,
|
||
revisionCost: revisionCost,
|
||
total: total
|
||
};
|
||
}
|
||
|
||
function formatReportDate(date) {
|
||
try {
|
||
return date.toLocaleString('fa-IR', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} catch (e) {
|
||
return date.toISOString();
|
||
}
|
||
}
|
||
|
||
function getRowReportDetail(row) {
|
||
var complexity = CATALOG[row.cat] && CATALOG[row.cat].hasComplexity
|
||
? COMPLEXITY_LABELS[row.complexity] || row.complexity
|
||
: '—';
|
||
var status = row.mode === 'reuse' ? 'Reuse' : 'جدید';
|
||
return complexity + ' · ' + status;
|
||
}
|
||
|
||
function reportKvTable(rows) {
|
||
var html = '<table class="report-table report-kv"><colgroup><col class="col-kv-key"><col class="col-kv-val"></colgroup><tbody>';
|
||
rows.forEach(function (row) {
|
||
html += '<tr><th contenteditable="true">' + escapeHtml(row[0]) + '</th><td contenteditable="true">' + escapeHtml(row[1]) + '</td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
return html;
|
||
}
|
||
|
||
function reportBlocksTableColgroup() {
|
||
return '<colgroup>' +
|
||
'<col class="col-block">' +
|
||
'<col class="col-detail">' +
|
||
'<col class="col-unit">' +
|
||
'<col class="col-cost">' +
|
||
'</colgroup>';
|
||
}
|
||
|
||
function buildProjectReportHtml() {
|
||
var archived = getArchivedPages();
|
||
var drafts = getDraftPages();
|
||
var finances = getProjectFinancials();
|
||
var now = new Date();
|
||
var parts = [];
|
||
|
||
parts.push('<div class="report-doc">');
|
||
parts.push('<h3 class="report-heading" contenteditable="true">' + escapeHtml(buildReportHeading()) + '</h3>');
|
||
|
||
var metaRows = [
|
||
['عنوان پروژه', getProjectTitle() || '—'],
|
||
['تاریخ گزارش', formatReportDate(now)],
|
||
['تعرفه هر Unit', fmt(UNIT_PRICE) + ' تومان'],
|
||
['صفحات تأییدشده', archived.length.toLocaleString('fa-IR')]
|
||
];
|
||
if (drafts.length) {
|
||
metaRows.push(['پیشنویسهای باز', drafts.length.toLocaleString('fa-IR') + ' — ' + drafts.map(function (p) { return p.name; }).join('، ')]);
|
||
}
|
||
parts.push(reportKvTable(metaRows));
|
||
|
||
parts.push('<h4 class="report-subheading" contenteditable="true">گزینههای پروژه</h4>');
|
||
parts.push(reportKvTable([
|
||
['محتوا', getSelectLabel('opt-content')],
|
||
['Responsive', getSelectLabel('opt-responsive')],
|
||
['Dark Mode', getSelectLabel('opt-dark')],
|
||
['دوزبانه', getSelectLabel('opt-bilingual')],
|
||
['Revision', finances.revisionUnits ? fmtUnit(finances.revisionUnits) + ' Unit' : 'ندارد']
|
||
]));
|
||
|
||
if (!archived.length) {
|
||
parts.push('<p class="report-empty" contenteditable="true">هنوز صفحهای آرشیو نشده — بعد از تحلیل URL، صفحه را آرشیو کنید.</p>');
|
||
} else {
|
||
parts.push('<h4 class="report-subheading" contenteditable="true">بلاکهای صفحات</h4>');
|
||
parts.push('<table class="report-table report-blocks-table">' + reportBlocksTableColgroup());
|
||
parts.push('<thead><tr>');
|
||
['Block', 'جزئیات', 'Unit', 'هزینه (تومان)'].forEach(function (h) {
|
||
parts.push('<th contenteditable="true">' + escapeHtml(h) + '</th>');
|
||
});
|
||
parts.push('</tr></thead><tbody>');
|
||
|
||
archived.forEach(function (page, pageIdx) {
|
||
var totals = calcPageTotals(page);
|
||
var pageLabel = 'صفحه ' + (pageIdx + 1).toLocaleString('fa-IR') + ': ' + page.name;
|
||
if (totals.pageMultiplier > 1) {
|
||
pageLabel += ' (ضریب ×' + totals.pageMultiplier.toLocaleString('fa-IR') + ' — ' +
|
||
totals.pageMultiplier.toLocaleString('fa-IR') + ' صفحه مشابه)';
|
||
}
|
||
if (page.sourceUrl) pageLabel += ' — ' + page.sourceUrl;
|
||
|
||
parts.push('<tr class="report-page-divider"><td colspan="4" contenteditable="true"><strong>' + escapeHtml(pageLabel) + '</strong></td></tr>');
|
||
|
||
var rows = getRowsForPage(page);
|
||
if (!rows.length) {
|
||
parts.push('<tr><td colspan="4" contenteditable="true">(بدون بلاک)</td></tr>');
|
||
} else {
|
||
rows.forEach(function (row) {
|
||
var units = calcRowUnits(row);
|
||
var cost = units * UNIT_PRICE;
|
||
var blockLabel = row.block;
|
||
if (row.label && row.label !== row.block) blockLabel += ' — ' + row.label;
|
||
parts.push('<tr>');
|
||
parts.push('<td contenteditable="true">' + escapeHtml(blockLabel) + '</td>');
|
||
parts.push('<td contenteditable="true">' + escapeHtml(getRowReportDetail(row)) + '</td>');
|
||
parts.push('<td class="report-num" contenteditable="true">' + escapeHtml(fmtUnit(units)) + '</td>');
|
||
parts.push('<td class="report-num" contenteditable="true">' + escapeHtml(fmt(cost)) + '</td>');
|
||
parts.push('</tr>');
|
||
});
|
||
}
|
||
|
||
parts.push('<tr class="report-subtotal">');
|
||
var subtotalLabel = 'جمع «' + page.name + '» — ' + getAnimationLabel(page);
|
||
if (totals.pageMultiplier > 1) {
|
||
subtotalLabel += ' — ضریب ×' + totals.pageMultiplier.toLocaleString('fa-IR') +
|
||
' (' + fmtUnit(totals.baseTotalUnits) + ' × ' + totals.pageMultiplier.toLocaleString('fa-IR') + ')';
|
||
}
|
||
parts.push('<td colspan="2" contenteditable="true">' + escapeHtml(subtotalLabel) + '</td>');
|
||
parts.push('<td class="report-num" contenteditable="true">' + escapeHtml(fmtUnit(totals.totalUnits)) + '</td>');
|
||
parts.push('<td class="report-num" contenteditable="true">' + escapeHtml(fmt(totals.cost)) + '</td>');
|
||
parts.push('</tr>');
|
||
});
|
||
|
||
parts.push('</tbody></table>');
|
||
}
|
||
|
||
parts.push('<h4 class="report-subheading" contenteditable="true">خلاصه مالی</h4>');
|
||
var summaryRows = [
|
||
['Unit بلاکها', fmtUnit(finances.projectBlockUnits)],
|
||
['Unit انیمیشن', fmtUnit(finances.projectAnimUnits)]
|
||
];
|
||
if (finances.revisionUnits) summaryRows.push(['Revision', fmtUnit(finances.revisionUnits) + ' Unit']);
|
||
summaryRows.push(['مجموع Unit', fmtUnit(finances.totalUnits)]);
|
||
summaryRows.push(['هزینه Blockها', fmt(finances.blocksCost) + ' تومان']);
|
||
if (finances.contentExtra) summaryRows.push(['اضافه محتوا', fmt(finances.contentExtra) + ' تومان']);
|
||
if (finances.responsiveExtra) summaryRows.push(['اضافه Responsive', fmt(finances.responsiveExtra) + ' تومان']);
|
||
if (finances.darkExtra) summaryRows.push(['اضافه Dark Mode', fmt(finances.darkExtra) + ' تومان']);
|
||
if (finances.bilingualExtra) summaryRows.push(['اضافه دوزبانه', fmt(finances.bilingualExtra) + ' تومان']);
|
||
if (finances.revisionCost) summaryRows.push(['هزینه Revision', fmt(finances.revisionCost) + ' تومان']);
|
||
summaryRows.push(['قیمت نهایی', fmt(finances.total) + ' تومان']);
|
||
parts.push(reportKvTable(summaryRows));
|
||
parts.push('</div>');
|
||
|
||
return parts.join('');
|
||
}
|
||
|
||
function buildProjectReportText() {
|
||
var body = getReportBodyEl();
|
||
if (body && body.innerText) return body.innerText.trim();
|
||
var tmp = document.createElement('div');
|
||
tmp.innerHTML = buildProjectReportHtml();
|
||
return tmp.innerText.trim();
|
||
}
|
||
|
||
function saveReportToStorage() {
|
||
var body = getReportBodyEl();
|
||
if (!body) return;
|
||
try {
|
||
localStorage.setItem('feep-price-report-html', body.innerHTML);
|
||
} catch (e) { /* ignore quota */ }
|
||
}
|
||
|
||
function renderProjectReport(pageTotalsMap, force) {
|
||
var body = getReportBodyEl();
|
||
if (!body) return;
|
||
if (state.reportEdited && !force) return;
|
||
body.innerHTML = buildProjectReportHtml();
|
||
saveReportToStorage();
|
||
}
|
||
|
||
function refreshProjectReport() {
|
||
state.reportEdited = false;
|
||
try {
|
||
localStorage.removeItem('feep-price-report-html');
|
||
} catch (e) { /* ignore */ }
|
||
renderProjectReport(null, true);
|
||
showReportToast('گزارش از دادههای فعلی بهروزرسانی شد.');
|
||
}
|
||
|
||
function onReportEdited() {
|
||
state.reportEdited = true;
|
||
saveReportToStorage();
|
||
}
|
||
|
||
function showReportToast(message) {
|
||
var toast = document.getElementById('calc-report-toast');
|
||
if (!toast) return;
|
||
toast.textContent = message;
|
||
toast.hidden = false;
|
||
clearTimeout(showReportToast._timer);
|
||
showReportToast._timer = setTimeout(function () {
|
||
toast.hidden = true;
|
||
}, 2500);
|
||
}
|
||
|
||
function copyProjectReport() {
|
||
var body = getReportBodyEl();
|
||
if (!body) return;
|
||
var text = buildProjectReportText();
|
||
if (!text) return;
|
||
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(function () {
|
||
showReportToast('متن گزارش در کلیپبورد کپی شد.');
|
||
}).catch(function () {
|
||
selectProjectReport();
|
||
try {
|
||
document.execCommand('copy');
|
||
showReportToast('متن گزارش در کلیپبورد کپی شد.');
|
||
} catch (e) {
|
||
showReportToast('متن را دستی انتخاب و کپی کنید (Ctrl+C).');
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
selectProjectReport();
|
||
document.execCommand('copy');
|
||
showReportToast('متن گزارش در کلیپبورد کپی شد.');
|
||
}
|
||
|
||
function selectProjectReport() {
|
||
var body = getReportBodyEl();
|
||
if (!body) return;
|
||
body.focus();
|
||
var range = document.createRange();
|
||
range.selectNodeContents(body);
|
||
var sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
|
||
function downloadProjectReport() {
|
||
var body = getReportBodyEl();
|
||
if (!body || !body.innerHTML.trim()) return;
|
||
var docTitle = getProjectTitle() ? 'گزارش پروژه — ' + getProjectTitle() : 'گزارش برآورد پروژه';
|
||
var htmlDoc = '<!DOCTYPE html><html lang="fa" dir="rtl"><head><meta charset="UTF-8">' +
|
||
'<title>' + escapeHtml(docTitle) + '</title>' +
|
||
'<style>body{font-family:Tahoma,sans-serif;padding:24px;line-height:1.6}' +
|
||
'table{width:100%;border-collapse:collapse;margin:12px 0;font-size:14px}' +
|
||
'th,td{border:1px solid #ccc;padding:8px 10px;text-align:right}' +
|
||
'thead th,tfoot th{background:#f4f4f5}h3,h4{margin:20px 0 8px}' +
|
||
'.report-total-row td,.report-total-row th{font-weight:700;background:#f4f4f5}</style></head><body>' +
|
||
body.innerHTML + '</body></html>';
|
||
var blob = new Blob([htmlDoc], { type: 'text/html;charset=utf-8' });
|
||
var url = URL.createObjectURL(blob);
|
||
var link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = getReportDownloadBasename() + '.html';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
showReportToast('فایل HTML گزارش دانلود شد.');
|
||
}
|
||
|
||
function downloadProjectReportText() {
|
||
var text = buildProjectReportText();
|
||
if (!text) return;
|
||
var blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
var url = URL.createObjectURL(blob);
|
||
var link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = getReportDownloadBasename() + '.txt';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
showReportToast('فایل متنی گزارش دانلود شد.');
|
||
}
|
||
|
||
function getReportPrintCss() {
|
||
var styleEl = document.getElementById('report-print-css');
|
||
return styleEl ? styleEl.textContent : '';
|
||
}
|
||
|
||
function buildReportPrintDocument() {
|
||
var body = getReportBodyEl();
|
||
var docTitle = getProjectTitle() ? 'گزارش پروژه — ' + getProjectTitle() : 'گزارش برآورد پروژه';
|
||
return '<!DOCTYPE html><html lang="fa" dir="rtl"><head><meta charset="UTF-8">' +
|
||
'<title>' + escapeHtml(docTitle) + '</title>' +
|
||
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
|
||
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
|
||
'<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" rel="stylesheet">' +
|
||
'<style>' + getReportPrintCss() + '</style></head><body>' +
|
||
'<div class="calc-project-report-body" dir="rtl">' + body.innerHTML + '</div>' +
|
||
'</body></html>';
|
||
}
|
||
|
||
function downloadProjectReportPdf() {
|
||
var source = getReportBodyEl();
|
||
if (!source || !source.innerHTML.trim()) {
|
||
if (!getArchivedPages().length) {
|
||
showReportToast('گزارش خالی است — ابتدا صفحهای آرشیو کنید.');
|
||
return;
|
||
}
|
||
showReportToast('گزارش خالی است — ابتدا «بهروزرسانی» را بزنید.');
|
||
return;
|
||
}
|
||
|
||
var btn = document.getElementById('calc-report-download-pdf');
|
||
var iframe = document.createElement('iframe');
|
||
iframe.setAttribute('aria-hidden', 'true');
|
||
iframe.style.cssText = 'position:fixed;width:0;height:0;border:0;opacity:0;pointer-events:none;';
|
||
document.body.appendChild(iframe);
|
||
|
||
var cleanedUp = false;
|
||
var printWin = iframe.contentWindow;
|
||
var printDoc = printWin.document;
|
||
|
||
function cleanup() {
|
||
if (cleanedUp) return;
|
||
cleanedUp = true;
|
||
printWin.removeEventListener('afterprint', cleanup);
|
||
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
printWin.addEventListener('afterprint', cleanup);
|
||
|
||
if (btn) btn.disabled = true;
|
||
showReportToast('پنجره چاپ باز شد — «ذخیره به PDF» یا Microsoft Print to PDF را انتخاب کنید.');
|
||
|
||
printDoc.open();
|
||
printDoc.write(buildReportPrintDocument());
|
||
printDoc.close();
|
||
|
||
function triggerPrint() {
|
||
printWin.focus();
|
||
printWin.print();
|
||
window.setTimeout(cleanup, 3000);
|
||
}
|
||
|
||
if (printDoc.fonts && printDoc.fonts.ready) {
|
||
printDoc.fonts.ready.then(triggerPrint).catch(triggerPrint);
|
||
} else {
|
||
window.setTimeout(triggerPrint, 400);
|
||
}
|
||
}
|
||
|
||
function recalculate() {
|
||
if (!rowsEl) return;
|
||
|
||
if (!isRendering) {
|
||
saveActivePageFromDom();
|
||
}
|
||
|
||
var projectBlockUnits = 0;
|
||
var projectAnimUnits = 0;
|
||
var pageTotalsMap = {};
|
||
|
||
state.pages.forEach(function (page) {
|
||
var totals = calcPageTotals(page);
|
||
pageTotalsMap[page.id] = totals;
|
||
projectBlockUnits += totals.blockUnits;
|
||
projectAnimUnits += totals.animUnits;
|
||
});
|
||
|
||
var revisionUnits = getRevisionUnits();
|
||
var totalUnits = projectBlockUnits + projectAnimUnits + revisionUnits;
|
||
var blocksCost = totalUnits * UNIT_PRICE;
|
||
|
||
var contentPct = getContentPercent();
|
||
var responsivePct = parseFloat(document.getElementById('opt-responsive').value) || 0;
|
||
var darkPct = parseFloat(document.getElementById('opt-dark').value) || 0;
|
||
var bilingualPct = parseFloat(document.getElementById('opt-bilingual').value) || 0;
|
||
|
||
var revisionCost = revisionUnits * UNIT_PRICE;
|
||
var contentExtra = blocksCost * contentPct;
|
||
var afterContent = blocksCost + contentExtra;
|
||
var responsiveExtra = afterContent * responsivePct;
|
||
var afterResponsive = afterContent + responsiveExtra;
|
||
var darkExtra = afterResponsive * darkPct;
|
||
var afterDark = afterResponsive + darkExtra;
|
||
var bilingualExtra = afterDark * bilingualPct;
|
||
var total = afterDark + bilingualExtra;
|
||
|
||
if (emptyEl) {
|
||
emptyEl.style.display = rowsEl.children.length ? 'none' : 'block';
|
||
}
|
||
|
||
var activeTotals = pageTotalsMap[activePageId] || { totalUnits: 0, cost: 0, pageMultiplier: 1 };
|
||
var activePage = getActivePage();
|
||
var pageTotalLabel = fmt(activeTotals.cost) + ' تومان';
|
||
if (activeTotals.pageMultiplier > 1) {
|
||
pageTotalLabel = '× ' + activeTotals.pageMultiplier.toLocaleString('fa-IR') + ' — ' + pageTotalLabel;
|
||
}
|
||
document.getElementById('sum-page-total').textContent = pageTotalLabel;
|
||
var pageMultHint = document.getElementById('sum-page-multiplier-hint');
|
||
if (pageMultHint) {
|
||
if (activeTotals.pageMultiplier > 1 && activePage) {
|
||
pageMultHint.hidden = false;
|
||
pageMultHint.textContent = 'قالب «' + activePage.name + '» — ' +
|
||
fmtUnit(activeTotals.baseTotalUnits || 0) + ' Unit × ' +
|
||
activeTotals.pageMultiplier.toLocaleString('fa-IR') + ' = ' +
|
||
fmtUnit(activeTotals.totalUnits) + ' Unit';
|
||
} else {
|
||
pageMultHint.hidden = true;
|
||
pageMultHint.textContent = '';
|
||
}
|
||
}
|
||
document.getElementById('sum-units').textContent = fmtUnit(totalUnits);
|
||
document.getElementById('sum-blocks').textContent = fmt(blocksCost) + ' تومان';
|
||
document.getElementById('sum-content').textContent = contentExtra ? fmt(contentExtra) + ' تومان' : '—';
|
||
document.getElementById('sum-responsive').textContent = responsiveExtra ? fmt(responsiveExtra) + ' تومان' : '—';
|
||
document.getElementById('sum-dark').textContent = darkExtra ? fmt(darkExtra) + ' تومان' : '—';
|
||
document.getElementById('sum-bilingual').textContent = bilingualExtra ? fmt(bilingualExtra) + ' تومان' : '—';
|
||
document.getElementById('sum-revision').textContent = revisionCost ? fmt(revisionCost) + ' تومان' : '—';
|
||
document.getElementById('sum-total').textContent = fmt(total) + ' تومان';
|
||
|
||
renderPageTabs(pageTotalsMap);
|
||
renderPagesList(pageTotalsMap);
|
||
renderArchiveList(pageTotalsMap);
|
||
renderProjectReport(pageTotalsMap);
|
||
updateReuseButton();
|
||
updatePageActions();
|
||
}
|
||
|
||
function updatePageActions() {
|
||
var page = getActivePage();
|
||
var archiveBtn = document.getElementById('calc-page-archive');
|
||
var unarchiveBtn = document.getElementById('calc-page-unarchive');
|
||
var banner = document.getElementById('calc-archive-banner');
|
||
var readonly = isPageReadOnly(page);
|
||
|
||
if (calcMainEl) {
|
||
calcMainEl.classList.remove('calc-main--readonly');
|
||
}
|
||
|
||
var pageNameEl = document.getElementById('calc-page-name');
|
||
if (pageNameEl) pageNameEl.readOnly = readonly;
|
||
|
||
var animEl = document.getElementById('opt-animation');
|
||
var animUnitsEl = document.getElementById('opt-anim-units');
|
||
if (animEl) animEl.disabled = readonly;
|
||
if (animUnitsEl) animUnitsEl.disabled = readonly;
|
||
var multEl = document.getElementById('opt-page-multiplier');
|
||
if (multEl) multEl.disabled = false;
|
||
|
||
if (banner) {
|
||
banner.hidden = !readonly;
|
||
}
|
||
if (archiveBtn) {
|
||
archiveBtn.hidden = readonly;
|
||
archiveBtn.disabled = !page || !page.rows.length;
|
||
}
|
||
if (unarchiveBtn) {
|
||
unarchiveBtn.hidden = !readonly;
|
||
}
|
||
var deleteArchiveBtn = document.getElementById('calc-page-delete-archive');
|
||
if (deleteArchiveBtn) {
|
||
deleteArchiveBtn.hidden = !readonly;
|
||
}
|
||
}
|
||
|
||
function renderPageTabs(pageTotalsMap) {
|
||
if (!tabsEl) return;
|
||
var drafts = getDraftPages();
|
||
if (!drafts.length) {
|
||
tabsEl.innerHTML = '<span style="font-size:0.8125rem;color:var(--text-muted)">پیشنویسی نیست — از آرشیو مشاهده کنید</span>';
|
||
return;
|
||
}
|
||
tabsEl.innerHTML = drafts.map(function (page) {
|
||
var totals = pageTotalsMap[page.id] || { cost: 0 };
|
||
var active = page.id === activePageId ? ' is-active' : '';
|
||
var multBadge = totals.pageMultiplier > 1
|
||
? ' <small class="tab-mult">×' + totals.pageMultiplier.toLocaleString('fa-IR') + '</small>'
|
||
: '';
|
||
return '<button type="button" class="calc-page-tab' + active + '" data-page-id="' + page.id + '" role="tab">' +
|
||
'<span>' + page.name + multBadge + '</span>' +
|
||
'<span class="tab-total">' + fmt(totals.cost) + '</span>' +
|
||
'</button>';
|
||
}).join('');
|
||
|
||
tabsEl.querySelectorAll('.calc-page-tab').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
switchPage(parseInt(btn.dataset.pageId, 10));
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderPagesList(pageTotalsMap) {
|
||
if (!pagesListEl) return;
|
||
var drafts = getDraftPages();
|
||
if (!drafts.length) {
|
||
pagesListEl.innerHTML = '<div class="calc-pages-summary-empty">پیشنویسی فعال نیست</div>';
|
||
return;
|
||
}
|
||
pagesListEl.innerHTML = drafts.map(function (page) {
|
||
var totals = pageTotalsMap[page.id] || { totalUnits: 0, cost: 0 };
|
||
var active = page.id === activePageId && !page.archived ? ' is-active' : '';
|
||
var multNote = totals.pageMultiplier > 1 ? ' ×' + totals.pageMultiplier.toLocaleString('fa-IR') : '';
|
||
return '<div class="calc-page-summary-item' + active + '" data-page-id="' + page.id + '">' +
|
||
'<span>' + page.name + multNote + ' <small style="opacity:0.7">(' + fmtUnit(totals.totalUnits) + 'u)</small></span>' +
|
||
'<span>' + fmt(totals.cost) + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
pagesListEl.querySelectorAll('.calc-page-summary-item').forEach(function (item) {
|
||
item.addEventListener('click', function () {
|
||
switchPage(parseInt(item.dataset.pageId, 10));
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderArchiveList(pageTotalsMap) {
|
||
if (!archiveListEl) return;
|
||
var archived = getArchivedPages();
|
||
if (!archived.length) {
|
||
archiveListEl.innerHTML = '<div class="calc-archive-empty">هنوز صفحهای آرشیو نشده</div>';
|
||
return;
|
||
}
|
||
archiveListEl.innerHTML = archived.map(function (page) {
|
||
var totals = pageTotalsMap[page.id] || { totalUnits: 0, cost: 0 };
|
||
var viewing = page.id === activePageId && page.archived ? ' is-viewing' : '';
|
||
var archMult = totals.pageMultiplier > 1
|
||
? ' <span class="archive-mult-badge">×' + totals.pageMultiplier.toLocaleString('fa-IR') + '</span>'
|
||
: '';
|
||
return '<div class="calc-archive-item' + viewing + '" data-page-id="' + page.id + '">' +
|
||
'<span class="archive-item-label">' + page.name + archMult + '<span class="archive-badge">آرشیو</span> <small style="opacity:0.7">(' + fmtUnit(totals.totalUnits) + 'u)</small></span>' +
|
||
'<span class="archive-item-actions">' +
|
||
'<span class="archive-item-price">' + fmt(totals.cost) + '</span>' +
|
||
'<button type="button" class="calc-archive-delete" data-page-id="' + page.id + '" title="حذف از آرشیو">×</button>' +
|
||
'</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
archiveListEl.querySelectorAll('.calc-archive-item').forEach(function (item) {
|
||
item.addEventListener('click', function (e) {
|
||
if (e.target.closest('.calc-archive-delete')) return;
|
||
switchPage(parseInt(item.dataset.pageId, 10));
|
||
});
|
||
});
|
||
|
||
archiveListEl.querySelectorAll('.calc-archive-delete').forEach(function (btn) {
|
||
btn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
deleteArchivedPageById(parseInt(btn.dataset.pageId, 10));
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateReuseButton() {
|
||
var group = document.getElementById('calc-reuse-group');
|
||
var select = document.getElementById('calc-reuse-select');
|
||
if (!group || !select || !activePageId) return;
|
||
var page = getActivePage();
|
||
if (!page) {
|
||
group.hidden = true;
|
||
return;
|
||
}
|
||
var registry = getReuseRegistryForPage(page);
|
||
if (!registry.length) {
|
||
group.hidden = true;
|
||
return;
|
||
}
|
||
group.hidden = false;
|
||
select.innerHTML = registry.map(function (r) {
|
||
return '<option value="' + r.sourcePageId + ':' + r.sourceRowId + '">' + r.block + ' — ' + r.pageName + '</option>';
|
||
}).join('');
|
||
}
|
||
|
||
function switchPage(pageId) {
|
||
if (pageId === activePageId) return;
|
||
saveActivePageFromDom();
|
||
activePageId = pageId;
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
function renderActivePage() {
|
||
var page = getActivePage();
|
||
if (!page) return;
|
||
|
||
isRendering = true;
|
||
|
||
document.getElementById('calc-page-name').value = page.name;
|
||
document.getElementById('opt-animation').value = page.animation;
|
||
document.getElementById('opt-anim-units').value = page.animUnits;
|
||
document.getElementById('anim-extra-wrap').classList.toggle('hidden', page.animation !== 'complex');
|
||
var multEl = document.getElementById('opt-page-multiplier');
|
||
if (multEl) multEl.value = getPageMultiplier(page);
|
||
|
||
document.getElementById('calc-page-delete').disabled = state.pages.length <= 1 || page.archived;
|
||
|
||
sanitizePageLayoutRows(page);
|
||
rowsEl.innerHTML = '';
|
||
getRowsForPage(page).forEach(function (rowData) {
|
||
if (rowData.mode === 'reuse' && !rowData.sourcePageName) {
|
||
var srcPage = state.pages.find(function (p) { return p.id === rowData.sourcePageId; });
|
||
rowData.sourcePageName = srcPage ? srcPage.name : 'صفحه قبل';
|
||
}
|
||
createRow(rowData);
|
||
});
|
||
|
||
if (!getRowsForPage(page).length && emptyEl) {
|
||
emptyEl.style.display = 'block';
|
||
}
|
||
|
||
isRendering = false;
|
||
}
|
||
|
||
function getSelectableBlocks(catKey, page) {
|
||
var blocks = Object.keys(CATALOG[catKey].blocks);
|
||
if (page && shouldExcludeLayoutForPage(page)) {
|
||
blocks = blocks.filter(function (b) { return !isLayoutBlock(b); });
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
function createRow(defaults) {
|
||
var isReuse = defaults && defaults.mode === 'reuse';
|
||
var rowDefaults = defaults || { mode: 'new', cat: 'general', reuse: 'new_block', complexity: 'medium' };
|
||
if (!rowDefaults.id) rowDefaults.id = nextRowId();
|
||
|
||
var cat = rowDefaults.cat || 'general';
|
||
var block = rowDefaults.block || Object.keys(CATALOG[cat].blocks)[0];
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'calc-row' + (isReuse ? ' is-reuse' : '');
|
||
row.dataset.id = String(rowDefaults.id);
|
||
row.dataset.mode = isReuse ? 'reuse' : 'new';
|
||
if (isReuse) {
|
||
row.dataset.sourcePageId = String(rowDefaults.sourcePageId);
|
||
row.dataset.sourceRowId = String(rowDefaults.sourceRowId);
|
||
if (rowDefaults.reuseMatchType) row.dataset.reuseMatchType = rowDefaults.reuseMatchType;
|
||
}
|
||
|
||
var reuseOptions = buildOptions(REUSE, REUSE_LABELS);
|
||
|
||
var badgeHtml = isReuse
|
||
? '<div class="reuse-badge">↩ Reuse از ' + (rowDefaults.sourcePageName || 'صفحه قبل') +
|
||
(rowDefaults.reuseMatchType && rowDefaults.reuseMatchType !== 'exact'
|
||
? ' <small>(' + (rowDefaults.reuseMatchType === 'similar-type' ? 'بلاک مشابه' : rowDefaults.reuseMatchType === 'fingerprint' ? 'ساختار مشابه' : 'دسته مشابه') + ')</small>'
|
||
: '') +
|
||
'</div>'
|
||
: '';
|
||
|
||
row.innerHTML = badgeHtml +
|
||
'<div class="calc-field calc-field-cat">' +
|
||
'<label>دسته</label>' +
|
||
'<select class="sel-cat">' + buildOptions(CATALOG, CAT_LABELS) + '</select>' +
|
||
'</div>' +
|
||
'<div class="calc-field calc-field-block">' +
|
||
'<label>Block</label>' +
|
||
'<select class="sel-block"></select>' +
|
||
'</div>' +
|
||
'<div class="calc-field calc-field-complexity">' +
|
||
'<label>پیچیدگی</label>' +
|
||
'<select class="sel-complexity">' + buildOptions(COMPLEXITY_LABELS) + '</select>' +
|
||
'</div>' +
|
||
'<div class="calc-field calc-field-reuse">' +
|
||
'<label>وضعیت</label>' +
|
||
'<select class="sel-reuse">' + reuseOptions + '</select>' +
|
||
'</div>' +
|
||
'<div class="calc-field-units units-display">' +
|
||
'<span class="unit-val">۰</span><span class="unit-cost"></span>' +
|
||
'</div>' +
|
||
'<button type="button" class="calc-remove" title="حذف">×</button>';
|
||
|
||
var selCat = row.querySelector('.sel-cat');
|
||
var selBlock = row.querySelector('.sel-block');
|
||
var selComplexity = row.querySelector('.sel-complexity');
|
||
var selReuse = row.querySelector('.sel-reuse');
|
||
var complexityField = row.querySelector('.calc-field-complexity');
|
||
|
||
selCat.value = cat;
|
||
|
||
function populateBlocks() {
|
||
var c = CATALOG[selCat.value];
|
||
var activePage = getActivePage();
|
||
selBlock.innerHTML = getSelectableBlocks(selCat.value, activePage).map(function (b) {
|
||
return '<option value="' + b + '">' + b + '</option>';
|
||
}).join('');
|
||
if (rowDefaults.block && c.blocks[rowDefaults.block] && (!activePage || !shouldExcludeLayoutForPage(activePage) || !isLayoutBlock(rowDefaults.block))) {
|
||
selBlock.value = rowDefaults.block;
|
||
} else if (selBlock.options.length) {
|
||
selBlock.selectedIndex = 0;
|
||
}
|
||
toggleComplexity();
|
||
}
|
||
|
||
function toggleComplexity() {
|
||
var has = CATALOG[selCat.value].hasComplexity;
|
||
complexityField.style.opacity = has ? '1' : '0.4';
|
||
selComplexity.disabled = !has;
|
||
}
|
||
|
||
function updateUnits() {
|
||
var base = getBlockUnit(selCat.value, selBlock.value, selComplexity.value);
|
||
var reuseKey = selReuse.value;
|
||
var factor = REUSE[reuseKey] != null ? REUSE[reuseKey] : 1;
|
||
var units = base * factor;
|
||
var cost = units * UNIT_PRICE;
|
||
row.querySelector('.unit-val').textContent = fmtUnit(units) + ' Unit';
|
||
row.querySelector('.unit-cost').textContent = fmt(cost) + ' تومان';
|
||
recalculate();
|
||
}
|
||
|
||
selCat.addEventListener('change', function () { populateBlocks(); updateUnits(); });
|
||
selBlock.addEventListener('change', updateUnits);
|
||
selComplexity.addEventListener('change', updateUnits);
|
||
selReuse.addEventListener('change', function () {
|
||
if (row.dataset.mode === 'reuse' && selReuse.value === 'new_block') {
|
||
row.dataset.mode = 'new';
|
||
row.classList.remove('is-reuse');
|
||
delete row.dataset.sourcePageId;
|
||
delete row.dataset.sourceRowId;
|
||
delete row.dataset.reuseMatchType;
|
||
var badge = row.querySelector('.reuse-badge');
|
||
if (badge) badge.remove();
|
||
}
|
||
updateUnits();
|
||
});
|
||
row.querySelector('.calc-remove').addEventListener('click', function () {
|
||
row.remove();
|
||
recalculate();
|
||
});
|
||
|
||
populateBlocks();
|
||
if (rowDefaults.complexity) selComplexity.value = rowDefaults.complexity;
|
||
if (rowDefaults.reuse) selReuse.value = rowDefaults.reuse;
|
||
updateUnits();
|
||
|
||
rowsEl.appendChild(row);
|
||
if (emptyEl) emptyEl.style.display = 'none';
|
||
|
||
return row;
|
||
}
|
||
|
||
function addReuseRow() {
|
||
var page = getActivePage();
|
||
if (!page) return;
|
||
|
||
var registry = getReuseRegistryForPage(page);
|
||
if (!registry.length) return;
|
||
|
||
var select = document.getElementById('calc-reuse-select');
|
||
var key = select ? select.value : '';
|
||
var choice = registry.find(function (r) {
|
||
return (r.sourcePageId + ':' + r.sourceRowId) === key;
|
||
}) || registry[0];
|
||
|
||
if (!page) return;
|
||
|
||
var newRow = {
|
||
id: nextRowId(),
|
||
mode: 'reuse',
|
||
sourcePageId: choice.sourcePageId,
|
||
sourceRowId: choice.sourceRowId,
|
||
sourcePageName: choice.pageName,
|
||
cat: choice.cat,
|
||
block: choice.block,
|
||
complexity: choice.complexity,
|
||
reuse: 'no_change'
|
||
};
|
||
|
||
createRow(newRow);
|
||
recalculate();
|
||
}
|
||
|
||
function addPage() {
|
||
saveActivePageFromDom();
|
||
var page = createPageData(getNextDraftPageName(), []);
|
||
page.projectPageNumber = getArchivedPages().length + 1;
|
||
state.pages.push(page);
|
||
activePageId = page.id;
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
function archivePage() {
|
||
var page = getActivePage();
|
||
if (!page || page.archived) return;
|
||
|
||
saveActivePageFromDom();
|
||
if (!page.rows.length) return;
|
||
|
||
if (!page.projectPageNumber) {
|
||
page.projectPageNumber = getArchivedPages().length + 1;
|
||
}
|
||
sanitizePageLayoutRows(page);
|
||
page.archived = true;
|
||
if (!page.archivedAt) {
|
||
page.archivedAt = new Date().toISOString();
|
||
}
|
||
|
||
var drafts = getDraftPages();
|
||
if (drafts.length) {
|
||
activePageId = drafts[0].id;
|
||
} else {
|
||
var newPage = createPageData(getNextDraftPageName(), []);
|
||
newPage.projectPageNumber = getArchivedPages().length + 1;
|
||
state.pages.push(newPage);
|
||
activePageId = newPage.id;
|
||
}
|
||
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
function unarchivePage() {
|
||
var page = getActivePage();
|
||
if (!page || !page.archived) return;
|
||
|
||
page.archived = false;
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
function removeReuseReferencesToPage(pageId) {
|
||
state.pages.forEach(function (p) {
|
||
p.rows = p.rows.filter(function (row) {
|
||
return !(row.mode === 'reuse' && row.sourcePageId === pageId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function switchAfterPageRemoved(removedIdx) {
|
||
var drafts = getDraftPages();
|
||
if (drafts.length) {
|
||
activePageId = drafts[0].id;
|
||
return;
|
||
}
|
||
if (state.pages.length) {
|
||
var next = state.pages[Math.min(removedIdx, state.pages.length - 1)];
|
||
activePageId = next.id;
|
||
return;
|
||
}
|
||
var newPage = createPageData(getNextDraftPageName(), []);
|
||
state.pages.push(newPage);
|
||
activePageId = newPage.id;
|
||
}
|
||
|
||
function deleteArchivedPageById(pageId) {
|
||
var page = state.pages.find(function (p) { return p.id === pageId; });
|
||
if (!page || !page.archived) return;
|
||
|
||
var label = page.name || 'این صفحه';
|
||
if (!window.confirm('صفحه آرشیوشده «' + label + '» حذف شود؟\nبلاکهای Reuse مرتبط با این صفحه هم حذف میشوند.')) {
|
||
return;
|
||
}
|
||
|
||
removeReuseReferencesToPage(pageId);
|
||
var idx = getPageIndex(pageId);
|
||
state.pages.splice(idx, 1);
|
||
|
||
if (activePageId === pageId) {
|
||
switchAfterPageRemoved(idx);
|
||
renderActivePage();
|
||
}
|
||
|
||
recalculate();
|
||
}
|
||
|
||
function deleteArchivedPage() {
|
||
var page = getActivePage();
|
||
if (!page || !page.archived) return;
|
||
deleteArchivedPageById(page.id);
|
||
}
|
||
|
||
function deletePage() {
|
||
var page = getActivePage();
|
||
if (!page || page.archived) return;
|
||
if (state.pages.length <= 1) return;
|
||
saveActivePageFromDom();
|
||
var idx = getPageIndex(activePageId);
|
||
state.pages.splice(idx, 1);
|
||
var next = state.pages[Math.min(idx, state.pages.length - 1)];
|
||
activePageId = next.id;
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
var VALID_COMPLEXITY = { simple: true, medium: true, complex: true };
|
||
|
||
function blockToSlug(name) {
|
||
return name.toLowerCase().replace(/\s+/g, '-');
|
||
}
|
||
|
||
function normalizeToken(str) {
|
||
return (str || '').trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
||
}
|
||
|
||
function getCatalogIndex() {
|
||
var index = [];
|
||
Object.keys(CATALOG).forEach(function (catKey) {
|
||
Object.keys(CATALOG[catKey].blocks).forEach(function (blockName) {
|
||
index.push({
|
||
cat: catKey,
|
||
block: blockName,
|
||
normalized: normalizeToken(blockName),
|
||
slug: blockToSlug(blockName),
|
||
compact: normalizeToken(blockName).replace(/\s/g, '')
|
||
});
|
||
});
|
||
});
|
||
return index;
|
||
}
|
||
|
||
var catalogIndex = getCatalogIndex();
|
||
|
||
function resolveCatalogBlock(blockName, catHint) {
|
||
if (!blockName) return null;
|
||
var normalized = normalizeToken(blockName);
|
||
var compact = normalized.replace(/\s/g, '');
|
||
var slug = blockName.trim().toLowerCase().replace(/\s+/g, '-');
|
||
|
||
if (catHint && CATALOG[catHint] && CATALOG[catHint].blocks[blockName]) {
|
||
return { cat: catHint, block: blockName };
|
||
}
|
||
|
||
for (var i = 0; i < catalogIndex.length; i++) {
|
||
var item = catalogIndex[i];
|
||
if (item.block === blockName) return { cat: item.cat, block: item.block };
|
||
if (item.normalized === normalized || item.compact === compact || item.slug === slug) {
|
||
return { cat: item.cat, block: item.block };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeComplexity(value) {
|
||
var v = (value || '').trim().toLowerCase();
|
||
return VALID_COMPLEXITY[v] ? v : 'medium';
|
||
}
|
||
|
||
function makeImportRow(cat, block, complexity, extra) {
|
||
var row = {
|
||
mode: 'new',
|
||
cat: cat,
|
||
block: block,
|
||
complexity: normalizeComplexity(complexity),
|
||
reuse: 'new_block'
|
||
};
|
||
if (extra) {
|
||
Object.keys(extra).forEach(function (k) {
|
||
row[k] = extra[k];
|
||
});
|
||
}
|
||
return row;
|
||
}
|
||
|
||
var BLOCK_CLASSIFIERS = [
|
||
{ cat: 'general', block: 'Navbar', weight: 10, test: function (el, ctx) {
|
||
if (el.matches('header, [role="banner"]')) return true;
|
||
if (el.matches('nav') && !el.closest('footer')) return true;
|
||
return /nav(bar|igation)?/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Footer', weight: 10, test: function (el, ctx) {
|
||
return el.matches('footer, [role="contentinfo"]') || /footer/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Hero', weight: 9, test: function (el, ctx) {
|
||
return /hero|jumbotron|masthead|page-header/i.test(ctx.hay) && !/feature/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Pricing', weight: 9, test: function (el, ctx) {
|
||
return /pricing|price-table|plans?/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'FAQ', weight: 9, test: function (el, ctx) {
|
||
return /faq|frequently-asked|accordion.*question/i.test(ctx.hay) || (el.querySelector('details, [data-slot="accordion"]') && /faq|question/i.test(ctx.hay));
|
||
}},
|
||
{ cat: 'general', block: 'Testimonial', weight: 8, test: function (el, ctx) {
|
||
return /testimonial|review|quote|customer-story/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Gallery', weight: 8, test: function (el, ctx) {
|
||
return /gallery|portfolio|lightbox/i.test(ctx.hay) || (el.querySelectorAll('img').length >= 4 && /gallery|portfolio/i.test(ctx.hay));
|
||
}},
|
||
{ cat: 'general', block: 'Feature Grid', weight: 8, test: function (el, ctx) {
|
||
return /feature|benefit|capabilit/i.test(ctx.hay) && (el.querySelectorAll('[class*="card"], [class*="grid"] > *').length >= 3 || /grid/i.test(ctx.hay));
|
||
}},
|
||
{ cat: 'general', block: 'Card Section', weight: 7, test: function (el, ctx) {
|
||
return el.querySelectorAll('[class*="card"]').length >= 2 && !/pricing|testimonial/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'CTA', weight: 8, test: function (el, ctx) {
|
||
return /(^|\s)cta(\s|$)|call-to-action|get-started/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Banner', weight: 7, test: function (el, ctx) {
|
||
return /banner|promo|announcement/i.test(ctx.hay) && !/hero/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Timeline', weight: 7, test: function (el, ctx) {
|
||
return /timeline|roadmap|history/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Sidebar', weight: 8, test: function (el, ctx) {
|
||
return el.matches('aside') || /sidebar|side-nav/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'general', block: 'Blog List', weight: 8, test: function (el, ctx) {
|
||
return /blog|article-list|posts?/i.test(ctx.hay) && el.querySelectorAll('article, [class*="post"]').length >= 2;
|
||
}},
|
||
{ cat: 'general', block: 'Contact Section', weight: 8, test: function (el, ctx) {
|
||
return /contact|get-in-touch/i.test(ctx.hay) || (el.querySelector('form') && /contact/i.test(ctx.hay));
|
||
}},
|
||
{ cat: 'dashboard', block: 'Data Table', weight: 9, test: function (el, ctx) {
|
||
return el.querySelector('table') && /table|data-grid|datatable/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'dashboard', block: 'Stat Card', weight: 8, test: function (el, ctx) {
|
||
return /stat|metric|kpi|counter/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'dashboard', block: 'Chart', weight: 8, test: function (el, ctx) {
|
||
return /chart|graph|analytics/i.test(ctx.hay) || el.querySelector('canvas, svg[class*="chart"]');
|
||
}},
|
||
{ cat: 'dashboard', block: 'Filter Bar', weight: 7, test: function (el, ctx) {
|
||
return /filter|facets?/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'dashboard', block: 'Widget', weight: 6, test: function (el, ctx) {
|
||
return /widget|panel/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'form', block: 'Form Section', weight: 9, test: function (el, ctx) {
|
||
return el.querySelector('form') && !/search|login|sign-?in/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'form', block: 'Multi Step', weight: 9, test: function (el, ctx) {
|
||
return /multi-?step|wizard|stepper/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'form', block: 'File Upload', weight: 8, test: function (el, ctx) {
|
||
return el.querySelector('input[type="file"]') || /upload|dropzone/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'interactive', block: 'Modal', weight: 9, test: function (el, ctx) {
|
||
return el.matches('[role="dialog"], [data-slot="dialog"], .modal') || /modal|dialog/i.test(ctx.hay);
|
||
}},
|
||
{ cat: 'interactive', block: 'Drawer', weight: 8, test: function (el, ctx) {
|
||
return /drawer|sheet|offcanvas/i.test(ctx.hay);
|
||
}}
|
||
];
|
||
|
||
var STRUCTURAL_HEURISTICS = [
|
||
{ selector: 'header, [role="banner"]', cat: 'general', block: 'Navbar' },
|
||
{ selector: 'nav:not(header nav):not(footer nav)', cat: 'general', block: 'Navbar' },
|
||
{ selector: 'footer, [role="contentinfo"]', cat: 'general', block: 'Footer' },
|
||
{ selector: '[class*="hero" i], [id*="hero" i], [data-slot*="hero" i]', cat: 'general', block: 'Hero' },
|
||
{ selector: '[class*="pricing" i], [id*="pricing" i]', cat: 'general', block: 'Pricing' },
|
||
{ selector: '[class*="faq" i], [id*="faq" i]', cat: 'general', block: 'FAQ' },
|
||
{ selector: '[class*="testimonial" i], [id*="testimonial" i]', cat: 'general', block: 'Testimonial' },
|
||
{ selector: '[class*="gallery" i], [id*="gallery" i]', cat: 'general', block: 'Gallery' },
|
||
{ selector: '[class*="sidebar" i], aside', cat: 'general', block: 'Sidebar' },
|
||
{ selector: '[class*="cta" i], [id*="cta" i]', cat: 'general', block: 'CTA' },
|
||
{ selector: '[class*="banner" i], [id*="banner" i]', cat: 'general', block: 'Banner' },
|
||
{ selector: '[class*="timeline" i], [id*="timeline" i]', cat: 'general', block: 'Timeline' },
|
||
{ selector: '[class*="feature-grid" i], [class*="features" i][class*="grid" i]', cat: 'general', block: 'Feature Grid' },
|
||
{ selector: '[class*="contact" i][class*="section" i], [id*="contact" i]', cat: 'general', block: 'Contact Section' },
|
||
{ selector: '[class*="data-table" i], table[data-slot], .data-table', cat: 'dashboard', block: 'Data Table' },
|
||
{ selector: '[class*="stat-card" i], [class*="stats" i]', cat: 'dashboard', block: 'Stat Card' },
|
||
{ selector: '[class*="chart" i]:not(canvas)', cat: 'dashboard', block: 'Chart' },
|
||
{ selector: '[role="dialog"], [data-slot="dialog"], .modal', cat: 'interactive', block: 'Modal' },
|
||
{ selector: '[data-slot="drawer"], .drawer', cat: 'interactive', block: 'Drawer' },
|
||
{ selector: '[data-slot="dropdown-menu"], .dropdown-menu', cat: 'interactive', block: 'Dropdown' }
|
||
];
|
||
|
||
function getElementHaystack(el) {
|
||
var parts = [];
|
||
if (el.id) parts.push(el.id);
|
||
if (el.className && typeof el.className === 'string') parts.push(el.className);
|
||
if (el.getAttribute('data-slot')) parts.push(el.getAttribute('data-slot'));
|
||
if (el.getAttribute('data-section')) parts.push(el.getAttribute('data-section'));
|
||
if (el.getAttribute('aria-label')) parts.push(el.getAttribute('aria-label'));
|
||
var heading = el.querySelector('h1, h2, h3');
|
||
if (heading) parts.push(heading.textContent || '');
|
||
return parts.join(' ').toLowerCase();
|
||
}
|
||
|
||
function classifyElement(el) {
|
||
var ctx = { hay: getElementHaystack(el) };
|
||
var best = null;
|
||
var bestWeight = 0;
|
||
|
||
BLOCK_CLASSIFIERS.forEach(function (rule) {
|
||
try {
|
||
if (rule.test(el, ctx) && rule.weight > bestWeight) {
|
||
best = rule;
|
||
bestWeight = rule.weight;
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
|
||
if (best) {
|
||
return { cat: best.cat, block: best.block, complexity: inferComplexity(el, best.block) };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function inferComplexity(el, blockName) {
|
||
var childCount = el.querySelectorAll('*').length;
|
||
var inputs = el.querySelectorAll('input, select, textarea, button').length;
|
||
var cards = el.querySelectorAll('[class*="card"], li, article').length;
|
||
|
||
if (blockName === 'Data Table' || blockName === 'Multi Step' || blockName === 'Kanban') {
|
||
if (childCount > 80 || inputs > 8) return 'complex';
|
||
if (childCount > 35 || inputs > 3) return 'medium';
|
||
return 'simple';
|
||
}
|
||
if (blockName === 'Hero' || blockName === 'Pricing' || blockName === 'Gallery') {
|
||
if (childCount > 60 || cards > 6) return 'complex';
|
||
if (childCount > 25 || cards > 3) return 'medium';
|
||
return 'simple';
|
||
}
|
||
if (childCount > 50 || inputs > 5 || cards > 5) return 'complex';
|
||
if (childCount > 20 || inputs > 2 || cards > 2) return 'medium';
|
||
return 'simple';
|
||
}
|
||
|
||
function findMainRoot(doc) {
|
||
return doc.querySelector('main, [role="main"], #main, .main, #content, .content, .page, [class*="page-content"]') || doc.body;
|
||
}
|
||
|
||
function isHiddenElement(el) {
|
||
if (!el || el.nodeType !== 1) return true;
|
||
if (el.closest('[hidden], [aria-hidden="true"], template, script, style, noscript')) return true;
|
||
var tag = el.tagName.toLowerCase();
|
||
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'link' || tag === 'meta') return true;
|
||
if (el.matches('[data-elementor-type="popup"], [data-elementor-type="floating-buttons"], .elementor-location-popup, .elementor-location-floating_buttons')) return true;
|
||
return false;
|
||
}
|
||
|
||
function getSectionText(el) {
|
||
return (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function hasMeaningfulContent(el) {
|
||
var text = getSectionText(el);
|
||
if (text.length > 12) return true;
|
||
return !!el.querySelector('img, svg, video, form, table, input, button, a[href], canvas');
|
||
}
|
||
|
||
function getHiddenBreakpoints(el) {
|
||
var cls = (el.className && typeof el.className === 'string') ? el.className : '';
|
||
var matches = cls.match(/elementor-hidden-([a-z_]+)/gi) || [];
|
||
return matches.map(function (m) { return m.toLowerCase(); });
|
||
}
|
||
|
||
function areResponsiveAlternates(a, b) {
|
||
var aHidden = getHiddenBreakpoints(a);
|
||
var bHidden = getHiddenBreakpoints(b);
|
||
if (!aHidden.length || !bHidden.length) return false;
|
||
if (aHidden.join('|') === bHidden.join('|')) return false;
|
||
var aWidgets = getWidgetFingerprint(a);
|
||
var bWidgets = getWidgetFingerprint(b);
|
||
if (aWidgets && aWidgets === bWidgets) return true;
|
||
var aHeading = (a.querySelector('h1, h2, h3') || {}).textContent || '';
|
||
var bHeading = (b.querySelector('h1, h2, h3') || {}).textContent || '';
|
||
if (aHeading && bHeading && aHeading.trim() === bHeading.trim()) return true;
|
||
return false;
|
||
}
|
||
|
||
function getWidgetFingerprint(el) {
|
||
var types = [];
|
||
el.querySelectorAll('[data-widget_type]').forEach(function (w) {
|
||
var t = w.getAttribute('data-widget_type');
|
||
if (!t) return;
|
||
if (t === 'image.default' || t === 'spacer.default') return;
|
||
types.push(t);
|
||
});
|
||
types.sort();
|
||
return types.join('|');
|
||
}
|
||
|
||
function isHeadingOnlySection(el) {
|
||
if (!el || el.matches('header, footer, nav')) return false;
|
||
var widgets = el.querySelectorAll('[data-widget_type]');
|
||
if (widgets.length > 1) return false;
|
||
var text = getSectionText(el);
|
||
if (text.length > 50) return false;
|
||
return !!el.querySelector('h1, h2, h3, h4');
|
||
}
|
||
|
||
function filterHeadingOnlySections(sections) {
|
||
return sections.filter(function (section) {
|
||
if (section.role === 'layout-header' || section.role === 'layout-footer' || section.role === 'explicit') {
|
||
return true;
|
||
}
|
||
return !isHeadingOnlySection(section.el);
|
||
}).map(function (section, idx) {
|
||
return Object.assign({}, section, { index: idx + 1 });
|
||
});
|
||
}
|
||
|
||
function finalizeSections(sections) {
|
||
return filterHeadingOnlySections(mergeResponsiveSections(sections));
|
||
}
|
||
|
||
function mergeResponsiveSections(sections) {
|
||
if (!sections.length) return [];
|
||
var merged = [];
|
||
var skip = Object.create(null);
|
||
|
||
sections.forEach(function (section, i) {
|
||
if (skip[i]) return;
|
||
var group = [section];
|
||
for (var j = i + 1; j < sections.length; j++) {
|
||
if (skip[j]) continue;
|
||
if (areResponsiveAlternates(section.el, sections[j].el)) {
|
||
group.push(sections[j]);
|
||
skip[j] = true;
|
||
}
|
||
}
|
||
merged.push({
|
||
el: section.el,
|
||
source: section.source,
|
||
role: section.role,
|
||
index: merged.length + 1,
|
||
mergedCount: group.length
|
||
});
|
||
});
|
||
|
||
return merged;
|
||
}
|
||
|
||
function discoverExplicitSections(doc) {
|
||
var sections = [];
|
||
function add(el, source) {
|
||
if (!el || isHiddenElement(el)) return;
|
||
sections.push({ el: el, source: source, role: 'explicit' });
|
||
}
|
||
|
||
doc.querySelectorAll('[data-feep-block]').forEach(function (el) { add(el, 'data-feep-block'); });
|
||
doc.querySelectorAll('[data-block]').forEach(function (el) { add(el, 'data-block'); });
|
||
doc.querySelectorAll('[data-block-name]').forEach(function (el) { add(el, 'data-block-name'); });
|
||
return sections;
|
||
}
|
||
|
||
function discoverElementorSections(doc) {
|
||
var sections = [];
|
||
var pageRoot = doc.querySelector('[data-elementor-type="wp-page"].elementor, .elementor-page.elementor');
|
||
|
||
if (pageRoot) {
|
||
Array.from(pageRoot.children).forEach(function (child) {
|
||
if (!child.matches('.e-con.e-parent')) return;
|
||
if (isHiddenElement(child) || !hasMeaningfulContent(child)) return;
|
||
sections.push({ el: child, source: 'elementor-section', role: 'content' });
|
||
});
|
||
}
|
||
|
||
var header = doc.querySelector('.elementor-location-header, header.elementor[data-elementor-type="header"]');
|
||
if (header && hasMeaningfulContent(header)) {
|
||
sections.unshift({ el: header, source: 'elementor-header', role: 'layout-header' });
|
||
}
|
||
|
||
var footer = doc.querySelector('.elementor-location-footer, footer.elementor[data-elementor-type="footer"]');
|
||
if (footer && hasMeaningfulContent(footer)) {
|
||
sections.push({ el: footer, source: 'elementor-footer', role: 'layout-footer' });
|
||
}
|
||
|
||
return sections;
|
||
}
|
||
|
||
function discoverSemanticSections(doc) {
|
||
var sections = [];
|
||
var bodyHeader = doc.querySelector('body > header, body > [role="banner"]');
|
||
var bodyFooter = doc.querySelector('body > footer, body > [role="contentinfo"]');
|
||
var bodyNav = doc.querySelector('body > nav');
|
||
var root = findMainRoot(doc);
|
||
|
||
if (bodyHeader && hasMeaningfulContent(bodyHeader)) {
|
||
sections.push({ el: bodyHeader, source: 'semantic-header', role: 'layout-header' });
|
||
} else if (bodyNav && hasMeaningfulContent(bodyNav)) {
|
||
sections.push({ el: bodyNav, source: 'semantic-nav', role: 'layout-header' });
|
||
}
|
||
|
||
var mainChildren = [];
|
||
if (root) {
|
||
Array.from(root.children).forEach(function (child) {
|
||
if (!child.matches('section, article, div, header, footer, aside, nav')) return;
|
||
if (isHiddenElement(child) || !hasMeaningfulContent(child)) return;
|
||
mainChildren.push(child);
|
||
});
|
||
}
|
||
|
||
if (!mainChildren.length) {
|
||
doc.querySelectorAll('main section, main article, body > section, body > article').forEach(function (el) {
|
||
if (isHiddenElement(el) || !hasMeaningfulContent(el)) return;
|
||
mainChildren.push(el);
|
||
});
|
||
}
|
||
|
||
var unique = mainChildren.filter(function (el, idx, arr) {
|
||
for (var i = 0; i < arr.length; i++) {
|
||
if (arr[i] !== el && arr[i].contains(el)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
unique.forEach(function (el) {
|
||
sections.push({ el: el, source: 'semantic-section', role: 'content' });
|
||
});
|
||
|
||
if (bodyFooter && hasMeaningfulContent(bodyFooter)) {
|
||
sections.push({ el: bodyFooter, source: 'semantic-footer', role: 'layout-footer' });
|
||
}
|
||
|
||
return sections;
|
||
}
|
||
|
||
function discoverPageSections(doc) {
|
||
var explicit = discoverExplicitSections(doc);
|
||
if (explicit.length) return finalizeSections(explicit);
|
||
|
||
var elementor = discoverElementorSections(doc);
|
||
if (elementor.length >= 2) return finalizeSections(elementor);
|
||
|
||
var semantic = discoverSemanticSections(doc);
|
||
if (semantic.length) return finalizeSections(semantic);
|
||
|
||
return [];
|
||
}
|
||
|
||
function classifyByRole(section) {
|
||
if (section.role === 'layout-header') {
|
||
return { cat: 'general', block: 'Navbar', complexity: inferComplexity(section.el, 'Navbar') };
|
||
}
|
||
if (section.role === 'layout-footer') {
|
||
return { cat: 'general', block: 'Footer', complexity: inferComplexity(section.el, 'Footer') };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function classifyByWidgets(el) {
|
||
var widgetTypes = [];
|
||
el.querySelectorAll('[data-widget_type]').forEach(function (w) {
|
||
widgetTypes.push(w.getAttribute('data-widget_type') || '');
|
||
});
|
||
var joined = widgetTypes.join(' ').toLowerCase();
|
||
|
||
if (/nadernavigation|nav-menu|mega-menu/i.test(joined) || el.querySelector('nav, .nader-navigation, #nav')) {
|
||
return { cat: 'general', block: 'Navbar', complexity: inferComplexity(el, 'Navbar') };
|
||
}
|
||
if (/tabswithimage|nested-tabs|tabs\./i.test(joined)) {
|
||
return { cat: 'dashboard', block: 'Tabs', complexity: inferComplexity(el, 'Tabs') };
|
||
}
|
||
if (/postsslider|posts-slider|blog|loop-grid|archive-posts/i.test(joined)) {
|
||
return { cat: 'general', block: 'Blog List', complexity: inferComplexity(el, 'Blog List') };
|
||
}
|
||
if (/infinitecss|logo-slider|carousel|swiper|image-carousel/i.test(joined)) {
|
||
return { cat: 'general', block: 'Testimonial', complexity: inferComplexity(el, 'Testimonial') };
|
||
}
|
||
if (/accordion|faq/i.test(joined)) {
|
||
return { cat: 'general', block: 'FAQ', complexity: inferComplexity(el, 'FAQ') };
|
||
}
|
||
if (/form\./i.test(joined) || (el.querySelector('form') && !el.querySelector('header, nav'))) {
|
||
return { cat: 'form', block: 'Form Section', complexity: inferComplexity(el, 'Form Section') };
|
||
}
|
||
if (/price-table|pricing/i.test(joined)) {
|
||
return { cat: 'general', block: 'Pricing', complexity: inferComplexity(el, 'Pricing') };
|
||
}
|
||
if (/animated-headline|slides|video/i.test(joined) && el.querySelector('h1')) {
|
||
return { cat: 'general', block: 'Hero', complexity: inferComplexity(el, 'Hero') };
|
||
}
|
||
if (/animated-headline/i.test(joined)) {
|
||
return { cat: 'general', block: 'Banner', complexity: inferComplexity(el, 'Banner') };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function classifySection(section) {
|
||
var roleMatch = classifyByRole(section);
|
||
if (roleMatch) return Object.assign({ label: roleMatch.block }, roleMatch);
|
||
|
||
var el = section.el;
|
||
var widgetMatch = classifyByWidgets(el);
|
||
if (widgetMatch) return Object.assign({ label: widgetMatch.block }, widgetMatch);
|
||
|
||
var classified = classifyElement(el);
|
||
if (classified) return Object.assign({ label: classified.block }, classified);
|
||
|
||
var heading = el.querySelector('h1, h2, h3');
|
||
var headingText = heading ? heading.textContent.trim() : '';
|
||
return {
|
||
cat: 'general',
|
||
block: 'Card Section',
|
||
complexity: inferComplexity(el, 'Card Section'),
|
||
label: headingText ? 'Card Section — ' + headingText.slice(0, 30) : 'Card Section'
|
||
};
|
||
}
|
||
|
||
function parseExplicitMarkers(doc) {
|
||
var rows = [];
|
||
|
||
doc.querySelectorAll('script[type="application/json"][data-feep-blocks], script#feep-blocks').forEach(function (el) {
|
||
try {
|
||
var data = JSON.parse(el.textContent || '[]');
|
||
var list = Array.isArray(data) ? data : (data.blocks || []);
|
||
list.forEach(function (item) {
|
||
if (!item || !item.block) return;
|
||
var match = resolveCatalogBlock(item.block, item.cat || item.category);
|
||
if (match) rows.push(makeImportRow(match.cat, match.block, item.complexity));
|
||
});
|
||
} catch (e) { /* invalid json */ }
|
||
});
|
||
|
||
var commentPattern = /feep:block(?::([^\s>]+))?(?::([^\s>]+))?(?::([^\s>]+))?/gi;
|
||
var walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT, null);
|
||
var commentNode;
|
||
while ((commentNode = walker.nextNode())) {
|
||
var text = commentNode.nodeValue || '';
|
||
var match;
|
||
commentPattern.lastIndex = 0;
|
||
while ((match = commentPattern.exec(text))) {
|
||
var c1 = match[1];
|
||
var c2 = match[2];
|
||
var c3 = match[3];
|
||
var catHint = null;
|
||
var blockName = c1;
|
||
var complexity = 'medium';
|
||
if (c2 && CATALOG[c1] && CATALOG[c1].blocks[c2]) {
|
||
catHint = c1;
|
||
blockName = c2;
|
||
complexity = c3 || 'medium';
|
||
} else if (c2) {
|
||
complexity = c2;
|
||
}
|
||
var resolved = resolveCatalogBlock(blockName, catHint);
|
||
if (resolved) rows.push(makeImportRow(resolved.cat, resolved.block, complexity));
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
function analyzePageFromHtml(html) {
|
||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||
var markerRows = parseExplicitMarkers(doc);
|
||
|
||
if (markerRows.length) {
|
||
return {
|
||
rows: markerRows,
|
||
pageTitle: getPageTitle(doc),
|
||
meta: {
|
||
phase1Count: markerRows.length,
|
||
phase2Count: markerRows.length,
|
||
mergedResponsive: 0,
|
||
source: 'markers'
|
||
}
|
||
};
|
||
}
|
||
|
||
var rawSections = discoverExplicitSections(doc);
|
||
if (!rawSections.length) {
|
||
rawSections = discoverElementorSections(doc);
|
||
}
|
||
if (rawSections.length < 2) {
|
||
rawSections = discoverSemanticSections(doc);
|
||
}
|
||
|
||
var phase1Count = rawSections.length;
|
||
var sections = finalizeSections(rawSections);
|
||
|
||
if (!sections.length) {
|
||
return {
|
||
rows: [],
|
||
pageTitle: getPageTitle(doc),
|
||
meta: { phase1Count: 0, phase2Count: 0, mergedResponsive: 0, source: 'none' }
|
||
};
|
||
}
|
||
|
||
var analyzed = sections.map(function (section) {
|
||
var explicitBlock = section.role === 'explicit' ? section.el.getAttribute('data-feep-block') || section.el.getAttribute('data-block') || section.el.getAttribute('data-block-name') : null;
|
||
var explicitCat = section.el.getAttribute('data-feep-category') || section.el.getAttribute('data-feep-cat') || section.el.getAttribute('data-block-category');
|
||
var explicitComplexity = section.el.getAttribute('data-feep-complexity') || section.el.getAttribute('data-block-complexity');
|
||
|
||
if (explicitBlock) {
|
||
var resolved = resolveCatalogBlock(explicitBlock, explicitCat);
|
||
if (resolved) {
|
||
return {
|
||
index: section.index,
|
||
cat: resolved.cat,
|
||
block: resolved.block,
|
||
complexity: normalizeComplexity(explicitComplexity),
|
||
label: resolved.block,
|
||
fingerprint: section.el ? getWidgetFingerprint(section.el) : '',
|
||
source: section.source
|
||
};
|
||
}
|
||
}
|
||
|
||
var classified = classifySection(section);
|
||
return {
|
||
index: section.index,
|
||
cat: classified.cat,
|
||
block: classified.block,
|
||
complexity: classified.complexity,
|
||
label: classified.label,
|
||
fingerprint: section.el ? getWidgetFingerprint(section.el) : '',
|
||
source: section.source
|
||
};
|
||
});
|
||
|
||
var rows = analyzed.map(function (item) {
|
||
return makeImportRow(item.cat, item.block, item.complexity, {
|
||
label: item.label || item.block,
|
||
fingerprint: item.fingerprint || ''
|
||
});
|
||
});
|
||
|
||
var mergedResponsive = Math.max(0, phase1Count - sections.length);
|
||
|
||
return {
|
||
rows: rows,
|
||
analyzed: analyzed,
|
||
pageTitle: getPageTitle(doc),
|
||
meta: {
|
||
phase1Count: phase1Count,
|
||
phase2Count: analyzed.length,
|
||
mergedResponsive: mergedResponsive,
|
||
source: sections[0] ? sections[0].source : 'none'
|
||
}
|
||
};
|
||
}
|
||
|
||
function getPageTitle(doc) {
|
||
var pageName = doc.querySelector('[data-feep-page-name]');
|
||
if (pageName) return pageName.getAttribute('data-feep-page-name');
|
||
return (doc.querySelector('title') && doc.querySelector('title').textContent || '').trim();
|
||
}
|
||
|
||
function parseBlocksFromHtml(html) {
|
||
var result = analyzePageFromHtml(html);
|
||
return {
|
||
rows: result.rows,
|
||
pageTitle: result.pageTitle,
|
||
meta: result.meta,
|
||
analyzed: result.analyzed
|
||
};
|
||
}
|
||
|
||
function fetchPageHtml(url) {
|
||
var localApi = '/api/fetch-page?url=' + encodeURIComponent(url);
|
||
return fetch(localApi, { method: 'GET', credentials: 'same-origin' })
|
||
.then(function (res) {
|
||
if (res.ok) return res.text();
|
||
throw new Error('local-api');
|
||
})
|
||
.catch(function () {
|
||
return fetch(url, { method: 'GET', credentials: 'omit' })
|
||
.then(function (res) {
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.text();
|
||
});
|
||
})
|
||
.catch(function () {
|
||
var proxyUrl = 'https://api.allorigins.win/raw?url=' + encodeURIComponent(url);
|
||
return fetch(proxyUrl, { method: 'GET' }).then(function (res) {
|
||
if (!res.ok) throw new Error('proxy');
|
||
return res.text();
|
||
});
|
||
});
|
||
}
|
||
|
||
function setImportStatus(type, message) {
|
||
var el = document.getElementById('calc-import-status');
|
||
if (!el) return;
|
||
el.hidden = false;
|
||
el.className = 'calc-import-status is-' + type;
|
||
el.textContent = message;
|
||
}
|
||
|
||
function clearImportStatus() {
|
||
var el = document.getElementById('calc-import-status');
|
||
if (!el) return;
|
||
el.hidden = true;
|
||
el.className = 'calc-import-status';
|
||
el.textContent = '';
|
||
}
|
||
|
||
function applyImportedRows(rows, pageTitle, options) {
|
||
var page = getActivePage();
|
||
if (!page || page.archived) {
|
||
setImportStatus('error', 'صفحه فعال قابل ویرایش نیست — یک پیشنویس انتخاب کنید.');
|
||
return false;
|
||
}
|
||
|
||
if (!rows.length) {
|
||
setImportStatus('error', 'هیچ بلاکی در این صفحه شناسایی نشد. از data-feep-block روی المانها استفاده کنید یا ساختار HTML را واضحتر کنید.');
|
||
return false;
|
||
}
|
||
|
||
saveActivePageFromDom();
|
||
|
||
if (options.replace) {
|
||
page.rows = rows.map(function (r) {
|
||
return Object.assign({ id: nextRowId() }, r);
|
||
});
|
||
} else {
|
||
rows.forEach(function (r) {
|
||
page.rows.push(Object.assign({ id: nextRowId() }, r));
|
||
});
|
||
}
|
||
|
||
resolveIntraPageReuseReferences(page);
|
||
dedupeRowsWithinPage(page);
|
||
|
||
if (options.useTitle && pageTitle) {
|
||
page.name = pageTitle.split(/[|\-–—]/)[0].trim() || pageTitle;
|
||
}
|
||
|
||
if (options.sourceUrl) {
|
||
page.sourceUrl = options.sourceUrl;
|
||
}
|
||
|
||
if (options.projectPageNumber) {
|
||
page.projectPageNumber = options.projectPageNumber;
|
||
}
|
||
|
||
sanitizePageLayoutRows(page);
|
||
|
||
renderActivePage();
|
||
recalculate();
|
||
return true;
|
||
}
|
||
|
||
function autoArchiveActivePage() {
|
||
var page = getActivePage();
|
||
if (!page || page.archived || !page.rows.length) return false;
|
||
|
||
saveActivePageFromDom();
|
||
if (!page.projectPageNumber) {
|
||
page.projectPageNumber = getArchivedPages().length + 1;
|
||
}
|
||
sanitizePageLayoutRows(page);
|
||
page.archived = true;
|
||
if (!page.archivedAt) {
|
||
page.archivedAt = new Date().toISOString();
|
||
}
|
||
|
||
var newPage = createPageData(getNextDraftPageName(), []);
|
||
newPage.projectPageNumber = getArchivedPages().length + 1;
|
||
state.pages.push(newPage);
|
||
activePageId = newPage.id;
|
||
renderActivePage();
|
||
recalculate();
|
||
return true;
|
||
}
|
||
|
||
function buildImportStatusMessage(pageNum, matched, meta, archived) {
|
||
var parts = [];
|
||
if (meta) {
|
||
parts.push('فاز ۱: ' + meta.phase1Count + ' بلاک شمارش شد');
|
||
if (meta.mergedResponsive) {
|
||
parts.push(meta.mergedResponsive + ' نسخه ریسپانسیو ادغام شد');
|
||
}
|
||
parts.push('فاز ۲: ' + meta.phase2Count + ' بلاک شناسایی شد');
|
||
}
|
||
parts.push('صفحه ' + pageNum + ' («' + matched.page.name + '»)');
|
||
if (matched.stats.reuseCount) parts.push(matched.stats.reuseCount + ' Reuse');
|
||
if (matched.stats.newCount) parts.push(matched.stats.newCount + ' جدید');
|
||
if (matched.stats.skippedLayout) parts.push(matched.stats.skippedLayout + ' هدر/فوتر حذف شد');
|
||
parts.push(archived ? 'فاز ۳: ثبت و آرشیو شد' : 'فاز ۳: به پیشنویس اضافه شد');
|
||
return parts.join(' — ');
|
||
}
|
||
|
||
function importFromUrl() {
|
||
var urlInput = document.getElementById('calc-import-url');
|
||
var btn = document.getElementById('calc-import-btn');
|
||
if (!urlInput || !btn) return;
|
||
|
||
var rawUrl = urlInput.value.trim();
|
||
if (!rawUrl) {
|
||
setImportStatus('error', 'لطفاً آدرس URL را وارد کنید.');
|
||
return;
|
||
}
|
||
|
||
var url;
|
||
try {
|
||
url = new URL(rawUrl).href;
|
||
} catch (e) {
|
||
setImportStatus('error', 'آدرس URL معتبر نیست.');
|
||
return;
|
||
}
|
||
|
||
var useTitle = document.getElementById('calc-import-title');
|
||
var autoArchive = document.getElementById('calc-import-auto-archive');
|
||
var existingPageWithUrl = findPageBySourceUrl(url);
|
||
var pageNum = getSequentialPageNumber();
|
||
var excludeLayout = pageNum > 1;
|
||
|
||
var page = getActivePage();
|
||
if (!page || page.archived) {
|
||
var drafts = getDraftPages();
|
||
if (drafts.length) {
|
||
activePageId = drafts[0].id;
|
||
page = getActivePage();
|
||
} else {
|
||
var freshPage = createPageData(getNextDraftPageName(), []);
|
||
state.pages.push(freshPage);
|
||
activePageId = freshPage.id;
|
||
page = freshPage;
|
||
}
|
||
}
|
||
|
||
btn.disabled = true;
|
||
setImportStatus('loading', 'در حال دریافت صفحه ' + pageNum + ' و تحلیل بلاکها…' +
|
||
(excludeLayout ? ' (بدون هدر/فوتر)' : ''));
|
||
|
||
fetchPageHtml(url)
|
||
.then(function (html) {
|
||
var parsed = resolveUrlAnalysis(url, html);
|
||
var matched;
|
||
if (pageNum === 1 && existingPageWithUrl) {
|
||
matched = matchRowsAsNew(parsed.rows, { excludeLayout: false });
|
||
} else {
|
||
matched = matchRowsWithReuse(parsed.rows, { excludeLayout: excludeLayout });
|
||
}
|
||
var applied = applyImportedRows(matched.rows, parsed.pageTitle, {
|
||
replace: true,
|
||
useTitle: useTitle ? useTitle.checked : true,
|
||
sourceUrl: url,
|
||
projectPageNumber: pageNum
|
||
});
|
||
|
||
if (!applied) return;
|
||
|
||
var importedPage = getActivePage();
|
||
var shouldArchive = !autoArchive || autoArchive.checked;
|
||
if (shouldArchive) {
|
||
autoArchiveActivePage();
|
||
}
|
||
|
||
setImportStatus('success', buildImportStatusMessage(pageNum, {
|
||
page: importedPage,
|
||
rows: matched.rows,
|
||
stats: matched.stats
|
||
}, parsed.meta, shouldArchive));
|
||
|
||
urlInput.value = '';
|
||
urlInput.focus();
|
||
})
|
||
.catch(function () {
|
||
setImportStatus('error', 'دریافت صفحه ممکن نشد. ابتدا با npm run dev سرور را اجرا کنید، سپس URL را دوباره امتحان کنید.');
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function initCalculator() {
|
||
rowsEl = document.getElementById('calc-rows');
|
||
emptyEl = document.getElementById('calc-empty');
|
||
tabsEl = document.getElementById('calc-page-tabs');
|
||
pagesListEl = document.getElementById('calc-pages-list');
|
||
archiveListEl = document.getElementById('calc-archive-list');
|
||
calcMainEl = document.getElementById('calc-main');
|
||
|
||
if (!rowsEl || !document.getElementById('calc-add')) {
|
||
return;
|
||
}
|
||
|
||
var homePage = createPageData('صفحه ۱', DEFAULT_ROWS);
|
||
homePage.projectPageNumber = 1;
|
||
state.pages.push(homePage);
|
||
activePageId = homePage.id;
|
||
|
||
document.getElementById('calc-add').addEventListener('click', function () {
|
||
createRow();
|
||
});
|
||
|
||
document.getElementById('calc-reuse').addEventListener('click', addReuseRow);
|
||
document.getElementById('calc-page-add').addEventListener('click', addPage);
|
||
document.getElementById('calc-page-delete').addEventListener('click', deletePage);
|
||
document.getElementById('calc-page-archive').addEventListener('click', archivePage);
|
||
document.getElementById('calc-page-unarchive').addEventListener('click', unarchivePage);
|
||
var deleteArchiveBtn = document.getElementById('calc-page-delete-archive');
|
||
if (deleteArchiveBtn) {
|
||
deleteArchiveBtn.addEventListener('click', deleteArchivedPage);
|
||
}
|
||
|
||
document.getElementById('calc-page-name').addEventListener('input', recalculate);
|
||
|
||
loadProjectTitleFromStorage();
|
||
var projectTitleEl = document.getElementById('opt-project-title');
|
||
if (projectTitleEl) {
|
||
projectTitleEl.addEventListener('input', function () {
|
||
saveProjectTitleToStorage();
|
||
recalculate();
|
||
});
|
||
}
|
||
|
||
['opt-content', 'opt-responsive', 'opt-dark', 'opt-bilingual', 'opt-animation'].forEach(function (id) {
|
||
document.getElementById(id).addEventListener('change', function () {
|
||
var wrap = document.getElementById('anim-extra-wrap');
|
||
if (wrap) {
|
||
wrap.classList.toggle('hidden', document.getElementById('opt-animation').value !== 'complex');
|
||
}
|
||
recalculate();
|
||
});
|
||
});
|
||
|
||
document.getElementById('opt-anim-units').addEventListener('input', recalculate);
|
||
var pageMultEl = document.getElementById('opt-page-multiplier');
|
||
if (pageMultEl) pageMultEl.addEventListener('input', recalculate);
|
||
var revisionEl = document.getElementById('opt-revision-units');
|
||
if (revisionEl) revisionEl.addEventListener('input', recalculate);
|
||
|
||
var importBtn = document.getElementById('calc-import-btn');
|
||
var importUrl = document.getElementById('calc-import-url');
|
||
if (importBtn) {
|
||
importBtn.addEventListener('click', importFromUrl);
|
||
}
|
||
if (importUrl) {
|
||
importUrl.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
importFromUrl();
|
||
}
|
||
});
|
||
importUrl.addEventListener('input', clearImportStatus);
|
||
}
|
||
|
||
var reportCopyBtn = document.getElementById('calc-report-copy');
|
||
var reportDownloadBtn = document.getElementById('calc-report-download');
|
||
var reportDownloadTxtBtn = document.getElementById('calc-report-download-txt');
|
||
var reportDownloadPdfBtn = document.getElementById('calc-report-download-pdf');
|
||
var reportSelectBtn = document.getElementById('calc-report-select');
|
||
var reportRefreshBtn = document.getElementById('calc-report-refresh');
|
||
var reportBody = getReportBodyEl();
|
||
|
||
if (reportCopyBtn) reportCopyBtn.addEventListener('click', copyProjectReport);
|
||
if (reportDownloadBtn) reportDownloadBtn.addEventListener('click', downloadProjectReport);
|
||
if (reportDownloadTxtBtn) reportDownloadTxtBtn.addEventListener('click', downloadProjectReportText);
|
||
if (reportDownloadPdfBtn) reportDownloadPdfBtn.addEventListener('click', downloadProjectReportPdf);
|
||
if (reportSelectBtn) reportSelectBtn.addEventListener('click', selectProjectReport);
|
||
if (reportRefreshBtn) reportRefreshBtn.addEventListener('click', refreshProjectReport);
|
||
|
||
if (reportBody) {
|
||
reportBody.addEventListener('input', onReportEdited);
|
||
try {
|
||
var savedHtml = localStorage.getItem('feep-price-report-html');
|
||
if (savedHtml) {
|
||
reportBody.innerHTML = savedHtml;
|
||
state.reportEdited = true;
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
renderActivePage();
|
||
recalculate();
|
||
}
|
||
|
||
function initCollapse() {
|
||
document.querySelectorAll('[data-collapse]').forEach(function (el) {
|
||
var trigger = el.classList.contains('table-collapse')
|
||
? el.querySelector('.table-collapse-trigger')
|
||
: el.querySelector('.section-trigger');
|
||
if (!trigger) return;
|
||
trigger.addEventListener('click', function () {
|
||
var open = el.classList.toggle('is-open');
|
||
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
});
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
initCalculator();
|
||
initCollapse();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|