diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
index 741f0b2..22e241d 100644
--- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
@@ -22,7 +22,7 @@
- ✓ {{ Math.max(0, (mapPreview.match(/\n/g)||[]).length - 1) }} lignes lues
+ ✓ {{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
@@ -40,7 +40,8 @@
- ✓ {{ Math.max(0, (giftPreview.match(/\n/g)||[]).length - 1) }} cartes-cadeaux
+ ✓ {{ countGiftRows(giftPreview) }} cartes-cadeaux
+ (format: {{ giftFormatHint }})
@@ -234,6 +235,44 @@ function readFile (file) {
async function readMapFile (file) { if (file) mapPreview.value = await readFile(file) }
async function readGiftFile (file) { if (file) giftPreview.value = await readFile(file) }
+// Counts must mirror the backend parser exactly so the user sees the same
+// numbers in the preview as what Step 2 will receive.
+
+// Map CSV format: 1-line title preamble + header row + N data rows.
+// Returns N (the # of contact lines, excluding the preamble and header).
+function countMapRows (text) {
+ if (!text) return 0
+ const lines = text.split(/\r?\n/).filter(l => l.trim())
+ // -1 for preamble, -1 for header
+ return Math.max(0, lines.length - 2)
+}
+
+// Giftbit CSV: TWO formats
+// 1. "Link Order" — headerless, one URL per line (each URL = 1 gift)
+// 2. "Campaign export" — header row + N data rows (-1 for header)
+// Detect like the backend: first non-empty line is a bare URL with no
+// separator → no header.
+function countGiftRows (text) {
+ if (!text) return 0
+ const cleaned = text.replace(/^/, '').trim()
+ if (!cleaned) return 0
+ const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
+ const isLinkOrder = /^https?:\/\/\S+$/.test(firstLine) &&
+ !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')
+ const lines = cleaned.split(/\r?\n/).filter(l => l.trim())
+ return isLinkOrder ? lines.length : Math.max(0, lines.length - 1)
+}
+
+// Show "Link Order" or "Campaign export" hint next to the gift count
+const giftFormatHint = computed(() => {
+ if (!giftPreview.value) return ''
+ const firstLine = giftPreview.value.replace(/^/, '').trim().split(/\r?\n/, 1)[0].trim()
+ if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
+ return 'Link Order'
+ }
+ return 'Campaign export'
+})
+
async function goPreview () {
if (!mapPreview.value || !giftPreview.value) return
parsing.value = true