@consilioweb/payload-seo-analyzer 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -643,6 +643,7 @@ All endpoints are prefixed with the configured `endpointBasePath` (default: `/se
643
643
  | `POST` | `/ai-generate` | Heuristic meta title/description generation (no API key needed) |
644
644
  | `POST` | `/ai-rewrite` | Rewrite a single meta field (Claude if `ANTHROPIC_API_KEY` set, else heuristic) |
645
645
  | `POST` | `/ai-optimize` | **AI SEO Optimize** — scan a page, propose optimized meta (Claude Opus 4.8 by default), server-validated; applied in one click from the sidebar |
646
+ | `POST` | `/ai-optimize-bulk` | **Bulk meta correction** — propose (dry-run) or apply optimized meta across many pages at once; powers the dashboard preview/export/apply |
646
647
  | `GET` `POST` | `/alt-text-audit` `/ai-alt-text` | **AI image alt-text** (Claude vision, `features.aiFeatures`) — list images missing alt, generate + apply |
647
648
  | `POST` | `/ai-content-brief` | **AI content brief** (`features.aiFeatures`) — outline, entities, questions, word count for a keyword |
648
649
  | `GET` | `/cannibalization` | Detect keyword cannibalization |
package/dist/client.cjs CHANGED
@@ -1066,8 +1066,15 @@ var fr = {
1066
1066
  markCornerstone: "Marquer pilier",
1067
1067
  unmarkCornerstone: "D\xE9marquer pilier",
1068
1068
  bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
1069
- bulkOptimizing: "Optimisation\u2026",
1069
+ bulkOptimizing: "Analyse\u2026",
1070
1070
  bulkConfirm: "Confirmer ?",
1071
+ bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
1072
+ bulkApply: "Appliquer",
1073
+ bulkApplying: "Application\u2026",
1074
+ bulkCancel: "Annuler",
1075
+ bulkExport: "Exporter CSV",
1076
+ bulkNoChanges: "Aucune correction n\xE9cessaire sur la s\xE9lection.",
1077
+ bulkCappedNote: "limite atteinte (affine la s\xE9lection)",
1071
1078
  searchPlaceholder: "Rechercher (titre, slug, keyword)...",
1072
1079
  allCollections: "Toutes les collections",
1073
1080
  allScores: "Tous les scores",
@@ -1661,8 +1668,15 @@ var en = {
1661
1668
  markCornerstone: "Mark as cornerstone",
1662
1669
  unmarkCornerstone: "Unmark cornerstone",
1663
1670
  bulkOptimizeMeta: "Optimize meta (AI)",
1664
- bulkOptimizing: "Optimizing\u2026",
1671
+ bulkOptimizing: "Analyzing\u2026",
1665
1672
  bulkConfirm: "Confirm?",
1673
+ bulkPreviewTitle: "Proposed meta corrections",
1674
+ bulkApply: "Apply",
1675
+ bulkApplying: "Applying\u2026",
1676
+ bulkCancel: "Cancel",
1677
+ bulkExport: "Export CSV",
1678
+ bulkNoChanges: "No corrections needed on the selection.",
1679
+ bulkCappedNote: "limit reached (narrow the selection)",
1666
1680
  searchPlaceholder: "Search (title, slug, keyword)...",
1667
1681
  allCollections: "All collections",
1668
1682
  allScores: "All scores",
@@ -9211,7 +9225,6 @@ function BulkActionBar({
9211
9225
  optimizing,
9212
9226
  t
9213
9227
  }) {
9214
- const [confirmOptimize, setConfirmOptimize] = React4.useState(false);
9215
9228
  if (count === 0) return null;
9216
9229
  return /* @__PURE__ */ jsxRuntime.jsxs(
9217
9230
  "div",
@@ -9261,18 +9274,11 @@ function BulkActionBar({
9261
9274
  "button",
9262
9275
  {
9263
9276
  onClick: () => {
9264
- if (optimizing) return;
9265
- if (confirmOptimize) {
9266
- setConfirmOptimize(false);
9267
- onOptimizeMeta();
9268
- } else {
9269
- setConfirmOptimize(true);
9270
- setTimeout(() => setConfirmOptimize(false), 4e3);
9271
- }
9277
+ if (!optimizing) onOptimizeMeta();
9272
9278
  },
9273
9279
  disabled: optimizing,
9274
9280
  style: { ...btnBase, backgroundColor: "#7c3aed", color: "#fff", opacity: optimizing ? 0.6 : 1 },
9275
- children: optimizing ? t.seoView.bulkOptimizing : confirmOptimize ? t.seoView.bulkConfirm : `\u2728 ${t.seoView.bulkOptimizeMeta}`
9281
+ children: optimizing ? t.seoView.bulkOptimizing : `\u2728 ${t.seoView.bulkOptimizeMeta}`
9276
9282
  }
9277
9283
  ),
9278
9284
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -9316,6 +9322,8 @@ function SeoView() {
9316
9322
  const [saving, setSaving] = React4.useState(false);
9317
9323
  const [saveError, setSaveError] = React4.useState(null);
9318
9324
  const [bulkOptimizing, setBulkOptimizing] = React4.useState(false);
9325
+ const [bulkApplying, setBulkApplying] = React4.useState(false);
9326
+ const [bulkPreview, setBulkPreview] = React4.useState(null);
9319
9327
  const PAGE_SIZE = 50;
9320
9328
  const fetchAudit = React4.useCallback(async (forceRefresh = false) => {
9321
9329
  setLoading(true);
@@ -9537,44 +9545,83 @@ function SeoView() {
9537
9545
  [selectedIds, fetchAudit]
9538
9546
  );
9539
9547
  const handleBulkOptimizeMeta = React4.useCallback(async () => {
9540
- const keys = Array.from(selectedIds);
9541
- if (keys.length === 0) return;
9548
+ const ids = Array.from(selectedIds).filter((k) => !k.startsWith("global:"));
9549
+ if (ids.length === 0) return;
9542
9550
  setBulkOptimizing(true);
9543
- for (const key of keys) {
9544
- const [collection, id] = key.split("::");
9545
- if (!collection || !id || collection.startsWith("global:")) continue;
9546
- try {
9547
- const res = await fetch("/api/seo-plugin/ai-optimize", {
9548
- method: "POST",
9549
- headers: { "Content-Type": "application/json" },
9550
- credentials: "include",
9551
- body: JSON.stringify({ collection, id })
9552
- });
9553
- if (!res.ok) continue;
9551
+ setBulkPreview(null);
9552
+ try {
9553
+ const res = await fetch("/api/seo-plugin/ai-optimize-bulk", {
9554
+ method: "POST",
9555
+ headers: { "Content-Type": "application/json" },
9556
+ credentials: "include",
9557
+ body: JSON.stringify({ ids, apply: false, limit: 100 })
9558
+ });
9559
+ if (res.ok) {
9554
9560
  const data = await res.json();
9555
- const sug = data.suggestions || {};
9556
- const patch = {};
9557
- if (sug.metaTitle || sug.metaDescription) {
9558
- patch.meta = { title: sug.metaTitle, description: sug.metaDescription };
9559
- }
9560
- if (sug.focusKeyword && sug.focusKeyword !== data.current?.focusKeyword) {
9561
- patch.focusKeyword = sug.focusKeyword;
9562
- }
9563
- if (Object.keys(patch).length > 0) {
9564
- await fetch(`/api/${collection}/${id}`, {
9565
- method: "PATCH",
9566
- headers: { "Content-Type": "application/json" },
9567
- credentials: "include",
9568
- body: JSON.stringify(patch)
9569
- });
9570
- }
9571
- } catch {
9561
+ const changed = (data.results || []).filter((r) => r.changed && !r.error);
9562
+ setBulkPreview({ results: changed, capped: !!data.capped, total: data.processed || 0 });
9572
9563
  }
9564
+ } catch {
9573
9565
  }
9574
9566
  setBulkOptimizing(false);
9567
+ }, [selectedIds]);
9568
+ const handleBulkApplyPreview = React4.useCallback(async () => {
9569
+ if (!bulkPreview || bulkPreview.results.length === 0) return;
9570
+ setBulkApplying(true);
9571
+ const corrections = bulkPreview.results.map((r) => ({
9572
+ collection: r.collection,
9573
+ id: r.id,
9574
+ metaTitle: r.after.metaTitle,
9575
+ metaDescription: r.after.metaDescription,
9576
+ focusKeyword: r.after.focusKeyword
9577
+ }));
9578
+ try {
9579
+ await fetch("/api/seo-plugin/ai-optimize-bulk", {
9580
+ method: "POST",
9581
+ headers: { "Content-Type": "application/json" },
9582
+ credentials: "include",
9583
+ body: JSON.stringify({ corrections, apply: true, limit: 100 })
9584
+ });
9585
+ } catch {
9586
+ }
9587
+ setBulkApplying(false);
9588
+ setBulkPreview(null);
9575
9589
  setSelectedIds(/* @__PURE__ */ new Set());
9576
9590
  fetchAudit();
9577
- }, [selectedIds, fetchAudit]);
9591
+ }, [bulkPreview, fetchAudit]);
9592
+ const handleExportBulkPreviewCsv = React4.useCallback(() => {
9593
+ if (!bulkPreview) return;
9594
+ const header = [
9595
+ "collection",
9596
+ "id",
9597
+ "title",
9598
+ "before_title",
9599
+ "after_title",
9600
+ "before_description",
9601
+ "after_description",
9602
+ "before_keyword",
9603
+ "after_keyword"
9604
+ ];
9605
+ const rows = bulkPreview.results.map((r) => [
9606
+ r.collection,
9607
+ r.id,
9608
+ r.title,
9609
+ r.before.metaTitle,
9610
+ r.after.metaTitle,
9611
+ r.before.metaDescription,
9612
+ r.after.metaDescription,
9613
+ r.before.focusKeyword,
9614
+ r.after.focusKeyword
9615
+ ]);
9616
+ const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(",")).join("\n");
9617
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
9618
+ const url = URL.createObjectURL(blob);
9619
+ const a = document.createElement("a");
9620
+ a.href = url;
9621
+ a.download = `seo-bulk-optimize-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
9622
+ a.click();
9623
+ URL.revokeObjectURL(url);
9624
+ }, [bulkPreview]);
9578
9625
  const handleInlineSave = React4.useCallback(
9579
9626
  async (item, metaTitle, metaDescription) => {
9580
9627
  setSaving(true);
@@ -10327,6 +10374,96 @@ function SeoView() {
10327
10374
  optimizing: bulkOptimizing,
10328
10375
  t
10329
10376
  }
10377
+ ),
10378
+ bulkPreview && /* @__PURE__ */ jsxRuntime.jsx(
10379
+ "div",
10380
+ {
10381
+ style: {
10382
+ position: "fixed",
10383
+ inset: 0,
10384
+ backgroundColor: "rgba(0,0,0,0.5)",
10385
+ zIndex: 100,
10386
+ display: "flex",
10387
+ alignItems: "center",
10388
+ justifyContent: "center",
10389
+ padding: 24,
10390
+ fontFamily: "var(--font-body, system-ui)"
10391
+ },
10392
+ onClick: () => {
10393
+ if (!bulkApplying) setBulkPreview(null);
10394
+ },
10395
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
10396
+ "div",
10397
+ {
10398
+ onClick: (e) => e.stopPropagation(),
10399
+ style: {
10400
+ width: "min(900px, 100%)",
10401
+ maxHeight: "85vh",
10402
+ display: "flex",
10403
+ flexDirection: "column",
10404
+ backgroundColor: V2.bgCard,
10405
+ border: `1px solid ${V2.border}`,
10406
+ borderRadius: 12,
10407
+ overflow: "hidden"
10408
+ },
10409
+ children: [
10410
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { padding: "16px 20px", borderBottom: `1px solid ${V2.border}` }, children: [
10411
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 16, fontWeight: 800, color: V2.text }, children: t.seoView.bulkPreviewTitle }),
10412
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { fontSize: 12, color: V2.textSecondary, marginTop: 4 }, children: [
10413
+ bulkPreview.results.length,
10414
+ " / ",
10415
+ bulkPreview.total,
10416
+ bulkPreview.capped ? ` \xB7 ${t.seoView.bulkCappedNote}` : ""
10417
+ ] })
10418
+ ] }),
10419
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { overflowY: "auto", padding: "8px 20px", flex: 1 }, children: [
10420
+ bulkPreview.results.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { padding: 24, textAlign: "center", color: V2.textSecondary, fontSize: 14 }, children: t.seoView.bulkNoChanges }),
10421
+ bulkPreview.results.map((r) => {
10422
+ const fields = [];
10423
+ if (r.before.metaTitle !== r.after.metaTitle) fields.push({ label: "Title", before: r.before.metaTitle, after: r.after.metaTitle });
10424
+ if (r.before.metaDescription !== r.after.metaDescription) fields.push({ label: "Description", before: r.before.metaDescription, after: r.after.metaDescription });
10425
+ if (r.before.focusKeyword !== r.after.focusKeyword) fields.push({ label: "Keyword", before: r.before.focusKeyword, after: r.after.focusKeyword });
10426
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { padding: "10px 0", borderBottom: `1px solid ${V2.border}` }, children: [
10427
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { fontSize: 13, fontWeight: 700, color: V2.text, marginBottom: 6 }, children: [
10428
+ r.title || `${r.collection}/${r.id}`,
10429
+ " ",
10430
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 10, color: V2.textSecondary, fontWeight: 400 }, children: r.collection })
10431
+ ] }),
10432
+ fields.map((f, i) => /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginBottom: 4, fontSize: 12 }, children: [
10433
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 10, fontWeight: 700, color: V2.textSecondary, textTransform: "uppercase" }, children: f.label }),
10434
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { color: V2.red, textDecoration: f.before ? "line-through" : "none", opacity: 0.75 }, children: f.before || t.seoAnalyzer.emptyValue }),
10435
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { color: V2.green, fontWeight: 600 }, children: f.after || t.seoAnalyzer.emptyValue })
10436
+ ] }, i))
10437
+ ] }, `${r.collection}::${r.id}`);
10438
+ })
10439
+ ] }),
10440
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { padding: "14px 20px", borderTop: `1px solid ${V2.border}`, display: "flex", justifyContent: "space-between", gap: 8 }, children: [
10441
+ /* @__PURE__ */ jsxRuntime.jsx(
10442
+ "button",
10443
+ {
10444
+ onClick: handleExportBulkPreviewCsv,
10445
+ disabled: bulkPreview.results.length === 0,
10446
+ style: { ...btnBase, backgroundColor: V2.cyan, color: "#000", opacity: bulkPreview.results.length === 0 ? 0.5 : 1 },
10447
+ children: t.seoView.bulkExport
10448
+ }
10449
+ ),
10450
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 8 }, children: [
10451
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setBulkPreview(null), disabled: bulkApplying, style: { ...btnBase, backgroundColor: V2.bg, color: V2.text }, children: t.seoView.bulkCancel }),
10452
+ /* @__PURE__ */ jsxRuntime.jsx(
10453
+ "button",
10454
+ {
10455
+ onClick: handleBulkApplyPreview,
10456
+ disabled: bulkApplying || bulkPreview.results.length === 0,
10457
+ style: { ...btnBase, backgroundColor: "#7c3aed", color: "#fff", opacity: bulkApplying || bulkPreview.results.length === 0 ? 0.6 : 1 },
10458
+ children: bulkApplying ? t.seoView.bulkApplying : `${t.seoView.bulkApply} (${bulkPreview.results.length})`
10459
+ }
10460
+ )
10461
+ ] })
10462
+ ] })
10463
+ ]
10464
+ }
10465
+ )
10466
+ }
10330
10467
  )
10331
10468
  ]
10332
10469
  }
package/dist/client.js CHANGED
@@ -1060,8 +1060,15 @@ var fr = {
1060
1060
  markCornerstone: "Marquer pilier",
1061
1061
  unmarkCornerstone: "D\xE9marquer pilier",
1062
1062
  bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
1063
- bulkOptimizing: "Optimisation\u2026",
1063
+ bulkOptimizing: "Analyse\u2026",
1064
1064
  bulkConfirm: "Confirmer ?",
1065
+ bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
1066
+ bulkApply: "Appliquer",
1067
+ bulkApplying: "Application\u2026",
1068
+ bulkCancel: "Annuler",
1069
+ bulkExport: "Exporter CSV",
1070
+ bulkNoChanges: "Aucune correction n\xE9cessaire sur la s\xE9lection.",
1071
+ bulkCappedNote: "limite atteinte (affine la s\xE9lection)",
1065
1072
  searchPlaceholder: "Rechercher (titre, slug, keyword)...",
1066
1073
  allCollections: "Toutes les collections",
1067
1074
  allScores: "Tous les scores",
@@ -1655,8 +1662,15 @@ var en = {
1655
1662
  markCornerstone: "Mark as cornerstone",
1656
1663
  unmarkCornerstone: "Unmark cornerstone",
1657
1664
  bulkOptimizeMeta: "Optimize meta (AI)",
1658
- bulkOptimizing: "Optimizing\u2026",
1665
+ bulkOptimizing: "Analyzing\u2026",
1659
1666
  bulkConfirm: "Confirm?",
1667
+ bulkPreviewTitle: "Proposed meta corrections",
1668
+ bulkApply: "Apply",
1669
+ bulkApplying: "Applying\u2026",
1670
+ bulkCancel: "Cancel",
1671
+ bulkExport: "Export CSV",
1672
+ bulkNoChanges: "No corrections needed on the selection.",
1673
+ bulkCappedNote: "limit reached (narrow the selection)",
1660
1674
  searchPlaceholder: "Search (title, slug, keyword)...",
1661
1675
  allCollections: "All collections",
1662
1676
  allScores: "All scores",
@@ -9205,7 +9219,6 @@ function BulkActionBar({
9205
9219
  optimizing,
9206
9220
  t
9207
9221
  }) {
9208
- const [confirmOptimize, setConfirmOptimize] = useState(false);
9209
9222
  if (count === 0) return null;
9210
9223
  return /* @__PURE__ */ jsxs(
9211
9224
  "div",
@@ -9255,18 +9268,11 @@ function BulkActionBar({
9255
9268
  "button",
9256
9269
  {
9257
9270
  onClick: () => {
9258
- if (optimizing) return;
9259
- if (confirmOptimize) {
9260
- setConfirmOptimize(false);
9261
- onOptimizeMeta();
9262
- } else {
9263
- setConfirmOptimize(true);
9264
- setTimeout(() => setConfirmOptimize(false), 4e3);
9265
- }
9271
+ if (!optimizing) onOptimizeMeta();
9266
9272
  },
9267
9273
  disabled: optimizing,
9268
9274
  style: { ...btnBase, backgroundColor: "#7c3aed", color: "#fff", opacity: optimizing ? 0.6 : 1 },
9269
- children: optimizing ? t.seoView.bulkOptimizing : confirmOptimize ? t.seoView.bulkConfirm : `\u2728 ${t.seoView.bulkOptimizeMeta}`
9275
+ children: optimizing ? t.seoView.bulkOptimizing : `\u2728 ${t.seoView.bulkOptimizeMeta}`
9270
9276
  }
9271
9277
  ),
9272
9278
  /* @__PURE__ */ jsx(
@@ -9310,6 +9316,8 @@ function SeoView() {
9310
9316
  const [saving, setSaving] = useState(false);
9311
9317
  const [saveError, setSaveError] = useState(null);
9312
9318
  const [bulkOptimizing, setBulkOptimizing] = useState(false);
9319
+ const [bulkApplying, setBulkApplying] = useState(false);
9320
+ const [bulkPreview, setBulkPreview] = useState(null);
9313
9321
  const PAGE_SIZE = 50;
9314
9322
  const fetchAudit = useCallback(async (forceRefresh = false) => {
9315
9323
  setLoading(true);
@@ -9531,44 +9539,83 @@ function SeoView() {
9531
9539
  [selectedIds, fetchAudit]
9532
9540
  );
9533
9541
  const handleBulkOptimizeMeta = useCallback(async () => {
9534
- const keys = Array.from(selectedIds);
9535
- if (keys.length === 0) return;
9542
+ const ids = Array.from(selectedIds).filter((k) => !k.startsWith("global:"));
9543
+ if (ids.length === 0) return;
9536
9544
  setBulkOptimizing(true);
9537
- for (const key of keys) {
9538
- const [collection, id] = key.split("::");
9539
- if (!collection || !id || collection.startsWith("global:")) continue;
9540
- try {
9541
- const res = await fetch("/api/seo-plugin/ai-optimize", {
9542
- method: "POST",
9543
- headers: { "Content-Type": "application/json" },
9544
- credentials: "include",
9545
- body: JSON.stringify({ collection, id })
9546
- });
9547
- if (!res.ok) continue;
9545
+ setBulkPreview(null);
9546
+ try {
9547
+ const res = await fetch("/api/seo-plugin/ai-optimize-bulk", {
9548
+ method: "POST",
9549
+ headers: { "Content-Type": "application/json" },
9550
+ credentials: "include",
9551
+ body: JSON.stringify({ ids, apply: false, limit: 100 })
9552
+ });
9553
+ if (res.ok) {
9548
9554
  const data = await res.json();
9549
- const sug = data.suggestions || {};
9550
- const patch = {};
9551
- if (sug.metaTitle || sug.metaDescription) {
9552
- patch.meta = { title: sug.metaTitle, description: sug.metaDescription };
9553
- }
9554
- if (sug.focusKeyword && sug.focusKeyword !== data.current?.focusKeyword) {
9555
- patch.focusKeyword = sug.focusKeyword;
9556
- }
9557
- if (Object.keys(patch).length > 0) {
9558
- await fetch(`/api/${collection}/${id}`, {
9559
- method: "PATCH",
9560
- headers: { "Content-Type": "application/json" },
9561
- credentials: "include",
9562
- body: JSON.stringify(patch)
9563
- });
9564
- }
9565
- } catch {
9555
+ const changed = (data.results || []).filter((r) => r.changed && !r.error);
9556
+ setBulkPreview({ results: changed, capped: !!data.capped, total: data.processed || 0 });
9566
9557
  }
9558
+ } catch {
9567
9559
  }
9568
9560
  setBulkOptimizing(false);
9561
+ }, [selectedIds]);
9562
+ const handleBulkApplyPreview = useCallback(async () => {
9563
+ if (!bulkPreview || bulkPreview.results.length === 0) return;
9564
+ setBulkApplying(true);
9565
+ const corrections = bulkPreview.results.map((r) => ({
9566
+ collection: r.collection,
9567
+ id: r.id,
9568
+ metaTitle: r.after.metaTitle,
9569
+ metaDescription: r.after.metaDescription,
9570
+ focusKeyword: r.after.focusKeyword
9571
+ }));
9572
+ try {
9573
+ await fetch("/api/seo-plugin/ai-optimize-bulk", {
9574
+ method: "POST",
9575
+ headers: { "Content-Type": "application/json" },
9576
+ credentials: "include",
9577
+ body: JSON.stringify({ corrections, apply: true, limit: 100 })
9578
+ });
9579
+ } catch {
9580
+ }
9581
+ setBulkApplying(false);
9582
+ setBulkPreview(null);
9569
9583
  setSelectedIds(/* @__PURE__ */ new Set());
9570
9584
  fetchAudit();
9571
- }, [selectedIds, fetchAudit]);
9585
+ }, [bulkPreview, fetchAudit]);
9586
+ const handleExportBulkPreviewCsv = useCallback(() => {
9587
+ if (!bulkPreview) return;
9588
+ const header = [
9589
+ "collection",
9590
+ "id",
9591
+ "title",
9592
+ "before_title",
9593
+ "after_title",
9594
+ "before_description",
9595
+ "after_description",
9596
+ "before_keyword",
9597
+ "after_keyword"
9598
+ ];
9599
+ const rows = bulkPreview.results.map((r) => [
9600
+ r.collection,
9601
+ r.id,
9602
+ r.title,
9603
+ r.before.metaTitle,
9604
+ r.after.metaTitle,
9605
+ r.before.metaDescription,
9606
+ r.after.metaDescription,
9607
+ r.before.focusKeyword,
9608
+ r.after.focusKeyword
9609
+ ]);
9610
+ const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(",")).join("\n");
9611
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
9612
+ const url = URL.createObjectURL(blob);
9613
+ const a = document.createElement("a");
9614
+ a.href = url;
9615
+ a.download = `seo-bulk-optimize-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
9616
+ a.click();
9617
+ URL.revokeObjectURL(url);
9618
+ }, [bulkPreview]);
9572
9619
  const handleInlineSave = useCallback(
9573
9620
  async (item, metaTitle, metaDescription) => {
9574
9621
  setSaving(true);
@@ -10321,6 +10368,96 @@ function SeoView() {
10321
10368
  optimizing: bulkOptimizing,
10322
10369
  t
10323
10370
  }
10371
+ ),
10372
+ bulkPreview && /* @__PURE__ */ jsx(
10373
+ "div",
10374
+ {
10375
+ style: {
10376
+ position: "fixed",
10377
+ inset: 0,
10378
+ backgroundColor: "rgba(0,0,0,0.5)",
10379
+ zIndex: 100,
10380
+ display: "flex",
10381
+ alignItems: "center",
10382
+ justifyContent: "center",
10383
+ padding: 24,
10384
+ fontFamily: "var(--font-body, system-ui)"
10385
+ },
10386
+ onClick: () => {
10387
+ if (!bulkApplying) setBulkPreview(null);
10388
+ },
10389
+ children: /* @__PURE__ */ jsxs(
10390
+ "div",
10391
+ {
10392
+ onClick: (e) => e.stopPropagation(),
10393
+ style: {
10394
+ width: "min(900px, 100%)",
10395
+ maxHeight: "85vh",
10396
+ display: "flex",
10397
+ flexDirection: "column",
10398
+ backgroundColor: V2.bgCard,
10399
+ border: `1px solid ${V2.border}`,
10400
+ borderRadius: 12,
10401
+ overflow: "hidden"
10402
+ },
10403
+ children: [
10404
+ /* @__PURE__ */ jsxs("div", { style: { padding: "16px 20px", borderBottom: `1px solid ${V2.border}` }, children: [
10405
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 16, fontWeight: 800, color: V2.text }, children: t.seoView.bulkPreviewTitle }),
10406
+ /* @__PURE__ */ jsxs("div", { style: { fontSize: 12, color: V2.textSecondary, marginTop: 4 }, children: [
10407
+ bulkPreview.results.length,
10408
+ " / ",
10409
+ bulkPreview.total,
10410
+ bulkPreview.capped ? ` \xB7 ${t.seoView.bulkCappedNote}` : ""
10411
+ ] })
10412
+ ] }),
10413
+ /* @__PURE__ */ jsxs("div", { style: { overflowY: "auto", padding: "8px 20px", flex: 1 }, children: [
10414
+ bulkPreview.results.length === 0 && /* @__PURE__ */ jsx("div", { style: { padding: 24, textAlign: "center", color: V2.textSecondary, fontSize: 14 }, children: t.seoView.bulkNoChanges }),
10415
+ bulkPreview.results.map((r) => {
10416
+ const fields = [];
10417
+ if (r.before.metaTitle !== r.after.metaTitle) fields.push({ label: "Title", before: r.before.metaTitle, after: r.after.metaTitle });
10418
+ if (r.before.metaDescription !== r.after.metaDescription) fields.push({ label: "Description", before: r.before.metaDescription, after: r.after.metaDescription });
10419
+ if (r.before.focusKeyword !== r.after.focusKeyword) fields.push({ label: "Keyword", before: r.before.focusKeyword, after: r.after.focusKeyword });
10420
+ return /* @__PURE__ */ jsxs("div", { style: { padding: "10px 0", borderBottom: `1px solid ${V2.border}` }, children: [
10421
+ /* @__PURE__ */ jsxs("div", { style: { fontSize: 13, fontWeight: 700, color: V2.text, marginBottom: 6 }, children: [
10422
+ r.title || `${r.collection}/${r.id}`,
10423
+ " ",
10424
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 10, color: V2.textSecondary, fontWeight: 400 }, children: r.collection })
10425
+ ] }),
10426
+ fields.map((f, i) => /* @__PURE__ */ jsxs("div", { style: { marginBottom: 4, fontSize: 12 }, children: [
10427
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 10, fontWeight: 700, color: V2.textSecondary, textTransform: "uppercase" }, children: f.label }),
10428
+ /* @__PURE__ */ jsx("div", { style: { color: V2.red, textDecoration: f.before ? "line-through" : "none", opacity: 0.75 }, children: f.before || t.seoAnalyzer.emptyValue }),
10429
+ /* @__PURE__ */ jsx("div", { style: { color: V2.green, fontWeight: 600 }, children: f.after || t.seoAnalyzer.emptyValue })
10430
+ ] }, i))
10431
+ ] }, `${r.collection}::${r.id}`);
10432
+ })
10433
+ ] }),
10434
+ /* @__PURE__ */ jsxs("div", { style: { padding: "14px 20px", borderTop: `1px solid ${V2.border}`, display: "flex", justifyContent: "space-between", gap: 8 }, children: [
10435
+ /* @__PURE__ */ jsx(
10436
+ "button",
10437
+ {
10438
+ onClick: handleExportBulkPreviewCsv,
10439
+ disabled: bulkPreview.results.length === 0,
10440
+ style: { ...btnBase, backgroundColor: V2.cyan, color: "#000", opacity: bulkPreview.results.length === 0 ? 0.5 : 1 },
10441
+ children: t.seoView.bulkExport
10442
+ }
10443
+ ),
10444
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8 }, children: [
10445
+ /* @__PURE__ */ jsx("button", { onClick: () => setBulkPreview(null), disabled: bulkApplying, style: { ...btnBase, backgroundColor: V2.bg, color: V2.text }, children: t.seoView.bulkCancel }),
10446
+ /* @__PURE__ */ jsx(
10447
+ "button",
10448
+ {
10449
+ onClick: handleBulkApplyPreview,
10450
+ disabled: bulkApplying || bulkPreview.results.length === 0,
10451
+ style: { ...btnBase, backgroundColor: "#7c3aed", color: "#fff", opacity: bulkApplying || bulkPreview.results.length === 0 ? 0.6 : 1 },
10452
+ children: bulkApplying ? t.seoView.bulkApplying : `${t.seoView.bulkApply} (${bulkPreview.results.length})`
10453
+ }
10454
+ )
10455
+ ] })
10456
+ ] })
10457
+ ]
10458
+ }
10459
+ )
10460
+ }
10324
10461
  )
10325
10462
  ]
10326
10463
  }