Files
price-page/calculator.js

2680 lines
96 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
}
})();