v4.0.0.2
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds mail-api/legacy-clients-seed.json from the three cleaned legacy JSON
|
||||
// files. The seed file is shipped into the mail-api Docker image and merged
|
||||
// into _client_profiles on first boot — never overwriting a live entry.
|
||||
//
|
||||
// Inputs:
|
||||
// data/legacy-clients.json
|
||||
// data/legacy-onboarding.json
|
||||
// data/legacy-contracts.json
|
||||
//
|
||||
// Output:
|
||||
// mail-api/legacy-clients-seed.json (email -> profile dict)
|
||||
//
|
||||
// The output shape mirrors the live _client_profiles entries that the mail-api
|
||||
// already serializes to JSON, so the merge path doesn't need to translate
|
||||
// anything.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const CLIENTS_FILE = resolve(ROOT, 'data/legacy-clients.json');
|
||||
const ONBOARDING_FILE = resolve(ROOT, 'data/legacy-onboarding.json');
|
||||
const CONTRACTS_FILE = resolve(ROOT, 'data/legacy-contracts.json');
|
||||
const OUTPUT = resolve(ROOT, 'mail-api/legacy-clients-seed.json');
|
||||
|
||||
for (const p of [CLIENTS_FILE, ONBOARDING_FILE, CONTRACTS_FILE]) {
|
||||
if (!existsSync(p)) {
|
||||
console.error(`Required legacy file missing: ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const clientsDoc = JSON.parse(readFileSync(CLIENTS_FILE, 'utf8'));
|
||||
const onboardingDoc = JSON.parse(readFileSync(ONBOARDING_FILE, 'utf8'));
|
||||
const contractsDoc = JSON.parse(readFileSync(CONTRACTS_FILE, 'utf8'));
|
||||
|
||||
const onboardingByEntryId = new Map();
|
||||
for (const rec of onboardingDoc.records ?? []) {
|
||||
if (rec?.legacy?.entryId) onboardingByEntryId.set(String(rec.legacy.entryId), rec);
|
||||
}
|
||||
|
||||
const contractsByEntryId = new Map();
|
||||
for (const rec of contractsDoc.records ?? []) {
|
||||
if (rec?.legacy?.entryId) contractsByEntryId.set(String(rec.legacy.entryId), rec);
|
||||
}
|
||||
|
||||
const importedAt = new Date().toISOString();
|
||||
|
||||
function pickOnboarding(client) {
|
||||
// Take the latest onboarding entry referenced for this client. Entry IDs
|
||||
// are numeric — higher == newer.
|
||||
const ids = (client.onboardingEntryIds ?? [])
|
||||
.map((id) => String(id))
|
||||
.filter((id) => onboardingByEntryId.has(id))
|
||||
.sort((a, b) => Number(b) - Number(a));
|
||||
return ids.length ? onboardingByEntryId.get(ids[0]) : null;
|
||||
}
|
||||
|
||||
function pickContract(client) {
|
||||
const ids = (client.contractEntryIds ?? [])
|
||||
.map((id) => String(id))
|
||||
.filter((id) => contractsByEntryId.has(id))
|
||||
.sort((a, b) => Number(b) - Number(a));
|
||||
return ids.length ? contractsByEntryId.get(ids[0]) : null;
|
||||
}
|
||||
|
||||
function nonEmpty(v) {
|
||||
return typeof v === 'string' ? v.trim() : v;
|
||||
}
|
||||
|
||||
function fullName(client) {
|
||||
const parts = [client.firstName, client.lastName].map(nonEmpty).filter(Boolean);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function buildProfile(client) {
|
||||
const onboarding = pickOnboarding(client);
|
||||
const contract = pickContract(client);
|
||||
|
||||
const dog = (client.dogs || [])[0] || {};
|
||||
const dogName = dog.name || onboarding?.dog?.name || '';
|
||||
const dogBreed = dog.breed || onboarding?.dog?.breed || '';
|
||||
const dogDob = dog.dateOfBirth || onboarding?.dog?.dateOfBirth || '';
|
||||
|
||||
// Pick a submittedAt timestamp from the best signal we have.
|
||||
const submittedAt =
|
||||
onboarding?.declaration?.signedOn ||
|
||||
onboarding?.legacy?.entryDate ||
|
||||
contract?.consent?.signedOn ||
|
||||
contract?.legacy?.entryDate ||
|
||||
'';
|
||||
|
||||
// Build the snapshot the admin dashboard expects when viewing a completed
|
||||
// client. The shape matches what OnboardingPage.svelte writes today:
|
||||
// { submittedAt, sections: [{ title, icon, fields: [{label, value}] }] }.
|
||||
const sections = [];
|
||||
if (onboarding) {
|
||||
const yn = (v) => (v === true ? 'Yes' : v === false ? 'No' : '—');
|
||||
const fv = (v) => (typeof v === 'string' && v.trim()) ? v.trim() : '—';
|
||||
sections.push({
|
||||
title: 'Owner Details',
|
||||
icon: 'fas fa-user',
|
||||
fields: [
|
||||
{ label: 'Owner First Name', value: fv(onboarding.owner?.firstName) },
|
||||
{ label: 'Owner Surname', value: fv(onboarding.owner?.lastName) },
|
||||
{ label: 'Email', value: fv(client.email) },
|
||||
{ label: 'Contact Number', value: fv(client.phoneRaw || client.phone) },
|
||||
{ label: 'Home Address', value: fv(client.address) },
|
||||
],
|
||||
});
|
||||
sections.push({
|
||||
title: 'Dog Details',
|
||||
icon: 'fas fa-paw',
|
||||
fields: [
|
||||
{ label: 'Dog Name', value: fv(onboarding.dog?.name) },
|
||||
{ label: 'Dog Surname', value: fv(onboarding.dog?.surname) },
|
||||
{ label: 'Breed', value: fv(onboarding.dog?.breed) },
|
||||
{ label: 'Date of Birth', value: fv(onboarding.dog?.dateOfBirth) },
|
||||
],
|
||||
});
|
||||
sections.push({
|
||||
title: 'Vet & Emergency',
|
||||
icon: 'fas fa-heart-pulse',
|
||||
fields: [
|
||||
{ label: 'Vet Name', value: fv(onboarding.vet?.name) },
|
||||
{ label: 'Vet Address', value: fv(onboarding.vet?.address) },
|
||||
{ label: 'Vet Phone', value: fv(onboarding.vet?.phoneRaw || onboarding.vet?.phone) },
|
||||
{ label: 'Emergency Contact Name', value: fv(onboarding.emergencyContact?.name) },
|
||||
{ label: 'Emergency Contact Phone', value: fv(onboarding.emergencyContact?.phoneRaw || onboarding.emergencyContact?.phone) },
|
||||
],
|
||||
});
|
||||
sections.push({
|
||||
title: 'Health',
|
||||
icon: 'fas fa-stethoscope',
|
||||
fields: [
|
||||
{ label: 'Vaccinations current?', value: yn(onboarding.health?.vaccinated) },
|
||||
{ label: 'Food allergies', value: onboarding.health?.foodAllergy?.present ? fv(onboarding.health.foodAllergy.detail) : yn(onboarding.health?.foodAllergy?.present) },
|
||||
{ label: 'Environmental allergies', value: onboarding.health?.environmentalAllergy?.present ? fv(onboarding.health.environmentalAllergy.detail) : yn(onboarding.health?.environmentalAllergy?.present) },
|
||||
{ label: 'Special diet', value: onboarding.health?.specialDiet?.present ? fv(onboarding.health.specialDiet.detail) : yn(onboarding.health?.specialDiet?.present) },
|
||||
{ label: 'Medication', value: onboarding.health?.medication?.present ? fv(onboarding.health.medication.detail) : yn(onboarding.health?.medication?.present) },
|
||||
],
|
||||
});
|
||||
sections.push({
|
||||
title: 'Behaviour',
|
||||
icon: 'fas fa-bone',
|
||||
fields: [
|
||||
{ label: 'Well socialised?', value: yn(onboarding.behaviour?.wellSocialised) },
|
||||
{ label: 'Dogs interacted with weekly', value: fv(onboarding.behaviour?.dogsInteractedWeekly) },
|
||||
{ label: 'Beach notes', value: fv(onboarding.behaviour?.beachNotes) },
|
||||
{ label: 'Dog park notes', value: fv(onboarding.behaviour?.dogParkNotes) },
|
||||
{ label: 'Bite history?', value: yn(onboarding.behaviour?.biteHistory) },
|
||||
{ label: 'Reactive to dogs?', value: yn(onboarding.behaviour?.reactiveToDogs) },
|
||||
{ label: 'Reactive to animals?', value: onboarding.behaviour?.reactiveToAnimals?.reactive ? fv(onboarding.behaviour.reactiveToAnimals.detail) : yn(onboarding.behaviour?.reactiveToAnimals?.reactive) },
|
||||
{ label: 'Reactive to children?', value: yn(onboarding.behaviour?.reactiveToChildren) },
|
||||
{ label: 'Reactive to people?', value: yn(onboarding.behaviour?.reactiveToPeople) },
|
||||
{ label: 'Desexed?', value: yn(onboarding.behaviour?.desexed) },
|
||||
{ label: 'Council registered?', value: yn(onboarding.behaviour?.councilRegistered) },
|
||||
{ label: 'Leash trained?', value: yn(onboarding.behaviour?.leashTrained) },
|
||||
{ label: 'Recall rating', value: onboarding.behaviour?.recallRating != null ? String(onboarding.behaviour.recallRating) : '—' },
|
||||
{ label: 'Ever run away?', value: yn(onboarding.behaviour?.ranAwayBefore) },
|
||||
{ label: 'Behaviour in car', value: fv(onboarding.behaviour?.carBehaviour) },
|
||||
{ label: 'Known commands', value: fv(onboarding.behaviour?.knownCommands) },
|
||||
],
|
||||
});
|
||||
sections.push({
|
||||
title: 'Other',
|
||||
icon: 'fas fa-file-signature',
|
||||
fields: [
|
||||
{ label: 'Additional notes', value: fv(onboarding.misc?.additionalNotes) },
|
||||
{ label: 'Social media', value: fv(onboarding.misc?.socialMediaAccount) },
|
||||
{ label: 'How did you hear?', value: fv(onboarding.misc?.howDidYouHear) },
|
||||
{ label: 'Referred by', value: fv(onboarding.misc?.referredBy) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const profile = {
|
||||
fullName: fullName(client),
|
||||
phone: client.phone || '',
|
||||
address: client.address || '',
|
||||
dogName: dogName || '',
|
||||
dogBreed: dogBreed || '',
|
||||
dogAge: dogDob || '',
|
||||
onboardingCompleted: Boolean(onboarding),
|
||||
onboardingSubmittedAt: submittedAt || '',
|
||||
onboardingSubmission: sections.length ? { submittedAt: submittedAt || '', sections } : undefined,
|
||||
// Mark legacy imports as archived by default — they pre-date the new
|
||||
// system and shouldn't pollute "active" outreach lists. The admin can
|
||||
// promote any of them back to 'active' from the dashboard.
|
||||
lifecycle: {
|
||||
status: 'archived',
|
||||
reason: 'Imported from legacy Gravity Forms data',
|
||||
changedAt: importedAt,
|
||||
changedBy: 'legacy-import',
|
||||
},
|
||||
// Provenance so we can always trace what came from the migration.
|
||||
legacy: {
|
||||
source: 'gravity-forms-csv',
|
||||
importedAt,
|
||||
onboardingEntryIds: client.onboardingEntryIds ?? [],
|
||||
contractEntryIds: client.contractEntryIds ?? [],
|
||||
onboardingPdfUrl: onboarding?.legacy?.pdfUrl ?? null,
|
||||
contractPdfUrl: contract?.legacy?.pdfUrl ?? null,
|
||||
signatureUrl: onboarding?.declaration?.signatureUrl ?? contract?.legacy?.signatureUrl ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
// Drop any keys that are empty strings so the mail-api merge path doesn't
|
||||
// store noisy blanks.
|
||||
for (const k of Object.keys(profile)) {
|
||||
if (profile[k] === '' || profile[k] === undefined) delete profile[k];
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
let written = 0;
|
||||
for (const client of clientsDoc.clients ?? []) {
|
||||
const email = String(client.email || '').trim().toLowerCase();
|
||||
if (!email) continue;
|
||||
out[email] = buildProfile(client);
|
||||
written++;
|
||||
}
|
||||
|
||||
writeFileSync(OUTPUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
emails: written,
|
||||
outputPath: OUTPUT,
|
||||
note: 'Ship this file in the mail-api Docker image. Merged on boot — never overwrites live entries.',
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env node
|
||||
// Cleans the legacy Gravity Forms contract CSV into structured JSON, then
|
||||
// enriches data/legacy-onboarding.json with the owner email + postal address
|
||||
// (and missing phone) wherever an onboarding row matches a contract row.
|
||||
//
|
||||
// Input:
|
||||
// goodwalk-contract-2026-05-20.csv (repo root)
|
||||
// data/legacy-onboarding.json (produced by clean-legacy-onboarding.mjs)
|
||||
//
|
||||
// Output:
|
||||
// data/legacy-contracts.json (cleaned contracts)
|
||||
// data/legacy-onboarding.json (enriched in place)
|
||||
// data/legacy-clients.json (owner-email-keyed merged view)
|
||||
//
|
||||
// Run: node scripts/clean-legacy-contracts.mjs
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const CONTRACT_CSV = resolve(ROOT, 'goodwalk-contract-2026-05-20.csv');
|
||||
const ONBOARDING_JSON = resolve(ROOT, 'data/legacy-onboarding.json');
|
||||
const CONTRACTS_OUT = resolve(ROOT, 'data/legacy-contracts.json');
|
||||
const CLIENTS_OUT = resolve(ROOT, 'data/legacy-clients.json');
|
||||
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let cur = [];
|
||||
let val = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inQ) {
|
||||
if (c === '"') {
|
||||
if (text[i + 1] === '"') { val += '"'; i++; }
|
||||
else inQ = false;
|
||||
} else val += c;
|
||||
} else {
|
||||
if (c === '"') inQ = true;
|
||||
else if (c === ',') { cur.push(val); val = ''; }
|
||||
else if (c === '\n') { cur.push(val); rows.push(cur); cur = []; val = ''; }
|
||||
else if (c === '\r') { /* skip */ }
|
||||
else val += c;
|
||||
}
|
||||
}
|
||||
if (val.length || cur.length) { cur.push(val); rows.push(cur); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
const trimOrNull = (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
return s ? s : null;
|
||||
};
|
||||
|
||||
const lowerKey = (v) => (v ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
function normalizePhone(raw) {
|
||||
const original = (raw ?? '').trim();
|
||||
if (!original) return { raw: null, e164: null };
|
||||
let digits = original.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return { raw: original, e164: '+' + digits.slice(1).replace(/\D/g, '') };
|
||||
digits = digits.replace(/\D/g, '');
|
||||
if (!digits) return { raw: original, e164: null };
|
||||
if (digits.startsWith('64')) return { raw: original, e164: '+' + digits };
|
||||
if (digits.startsWith('0')) return { raw: original, e164: '+64' + digits.slice(1) };
|
||||
return { raw: original, e164: null };
|
||||
}
|
||||
|
||||
function composeAddress({ street, line2, city, suburb, postal, country }) {
|
||||
const parts = [street, line2, suburb, city, postal, country]
|
||||
.map((p) => (p ?? '').trim())
|
||||
.filter(Boolean);
|
||||
// De-dupe consecutive identical fragments (suburb sometimes duplicates city).
|
||||
const deduped = [];
|
||||
for (const p of parts) {
|
||||
if (!deduped.length || deduped[deduped.length - 1].toLowerCase() !== p.toLowerCase()) {
|
||||
deduped.push(p);
|
||||
}
|
||||
}
|
||||
return deduped.length ? deduped.join(', ') : null;
|
||||
}
|
||||
|
||||
// -- Parse contract CSV --------------------------------------------------------
|
||||
const raw = readFileSync(CONTRACT_CSV, 'utf8').replace(/^/, '');
|
||||
const rows = parseCsv(raw);
|
||||
const headers = rows[0].map((h) => h.trim());
|
||||
const data = rows.slice(1).filter((r) => r.some((c) => (c ?? '').trim() !== ''));
|
||||
const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
|
||||
const col = (row, name) => row[idx[name]] ?? '';
|
||||
|
||||
const contracts = data.map((row) => {
|
||||
const first = trimOrNull(col(row, 'Owners Name (First Name)'));
|
||||
const middle = trimOrNull(col(row, 'Owners Name (Middle)'));
|
||||
const last = trimOrNull(col(row, 'Owners Name (Last Name/Surname)'));
|
||||
const fullName = [first, middle, last].filter(Boolean).join(' ') || null;
|
||||
const phone = normalizePhone(col(row, 'Phone'));
|
||||
|
||||
return {
|
||||
legacy: {
|
||||
entryId: trimOrNull(col(row, 'Entry Id')),
|
||||
entryDate: trimOrNull(col(row, 'Entry Date')),
|
||||
dateUpdated: trimOrNull(col(row, 'Date Updated')),
|
||||
createdByUserId: trimOrNull(col(row, 'Created By (User Id)')),
|
||||
sourceUrl: trimOrNull(col(row, 'Source Url')),
|
||||
userAgent: trimOrNull(col(row, 'User Agent')),
|
||||
userIp: trimOrNull(col(row, 'User IP')),
|
||||
pdfUrl: trimOrNull(col(row, 'PDF: PDF Label')),
|
||||
signatureUrl: trimOrNull(col(row, 'Owner Signature')),
|
||||
},
|
||||
owner: {
|
||||
firstName: first,
|
||||
middleName: middle,
|
||||
lastName: last,
|
||||
fullName,
|
||||
email: trimOrNull(col(row, "Owner's email (Enter Email)")),
|
||||
phone: phone.e164,
|
||||
phoneRaw: phone.raw,
|
||||
address: composeAddress({
|
||||
street: col(row, 'Residential Address (Street Address)'),
|
||||
line2: col(row, 'Residential Address (Address Line 2)'),
|
||||
city: col(row, 'Residential Address (City)'),
|
||||
suburb: col(row, 'Residential Address (Suburb)'),
|
||||
postal: col(row, 'Residential Address (ZIP / Postal Code)'),
|
||||
country: col(row, 'Residential Address (Country)'),
|
||||
}),
|
||||
addressParts: {
|
||||
street: trimOrNull(col(row, 'Residential Address (Street Address)')),
|
||||
line2: trimOrNull(col(row, 'Residential Address (Address Line 2)')),
|
||||
suburb: trimOrNull(col(row, 'Residential Address (Suburb)')),
|
||||
city: trimOrNull(col(row, 'Residential Address (City)')),
|
||||
postalCode: trimOrNull(col(row, 'Residential Address (ZIP / Postal Code)')),
|
||||
country: trimOrNull(col(row, 'Residential Address (Country)')),
|
||||
},
|
||||
},
|
||||
dog: {
|
||||
fullName: trimOrNull(col(row, "Dog's name (include surname)")),
|
||||
},
|
||||
consent: {
|
||||
checked: (col(row, 'Consent (Consent)') ?? '').trim().toLowerCase() === 'checked',
|
||||
text: trimOrNull(col(row, 'Consent (Text)')),
|
||||
signedOn: trimOrNull(col(row, 'Date contract signed')),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// -- Build contract lookup -----------------------------------------------------
|
||||
// Some owners appear twice (re-signing) — keep the highest entryId per key.
|
||||
function keepNewer(map, key, contract) {
|
||||
const existing = map.get(key);
|
||||
if (!existing) { map.set(key, contract); return; }
|
||||
const a = Number(contract.legacy.entryId) || 0;
|
||||
const b = Number(existing.legacy.entryId) || 0;
|
||||
if (a > b) map.set(key, contract);
|
||||
}
|
||||
|
||||
const byNameKey = new Map(); // "last|first"
|
||||
const byLastKey = new Map(); // "last"
|
||||
const byDogKey = new Map(); // dog full name lowercased
|
||||
const byDogFirstWord = new Map(); // first token of dog name (handles surname mismatches)
|
||||
|
||||
for (const c of contracts) {
|
||||
const last = lowerKey(c.owner.lastName);
|
||||
const first = lowerKey(c.owner.firstName);
|
||||
if (last && first) keepNewer(byNameKey, `${last}|${first}`, c);
|
||||
if (last) keepNewer(byLastKey, last, c);
|
||||
if (c.dog.fullName) {
|
||||
keepNewer(byDogKey, lowerKey(c.dog.fullName), c);
|
||||
const firstToken = lowerKey(c.dog.fullName).split(/\s+/)[0];
|
||||
if (firstToken) keepNewer(byDogFirstWord, `${firstToken}|${last}`, c);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Enrich onboarding ---------------------------------------------------------
|
||||
if (!existsSync(ONBOARDING_JSON)) {
|
||||
console.error(`Onboarding JSON not found at ${ONBOARDING_JSON}. Run clean-legacy-onboarding.mjs first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const onboardingPayload = JSON.parse(readFileSync(ONBOARDING_JSON, 'utf8'));
|
||||
|
||||
let matched = 0;
|
||||
let backfilledEmail = 0;
|
||||
let backfilledAddress = 0;
|
||||
let backfilledPhone = 0;
|
||||
const unmatched = [];
|
||||
|
||||
for (const rec of onboardingPayload.records) {
|
||||
const last = lowerKey(rec.owner.lastName);
|
||||
const first = lowerKey(rec.owner.firstName);
|
||||
const dogFull = lowerKey([rec.dog.name, rec.dog.surname].filter(Boolean).join(' '));
|
||||
const dogFirst = lowerKey(rec.dog.name);
|
||||
|
||||
let match = null;
|
||||
let matchedBy = null;
|
||||
if (last && first && byNameKey.has(`${last}|${first}`)) {
|
||||
match = byNameKey.get(`${last}|${first}`);
|
||||
matchedBy = 'owner_name';
|
||||
} else if (dogFull && byDogKey.has(dogFull)) {
|
||||
match = byDogKey.get(dogFull);
|
||||
matchedBy = 'dog_full_name';
|
||||
} else if (dogFirst && last && byDogFirstWord.has(`${dogFirst}|${last}`)) {
|
||||
match = byDogFirstWord.get(`${dogFirst}|${last}`);
|
||||
matchedBy = 'dog_first_owner_last';
|
||||
} else if (last && byLastKey.has(last)) {
|
||||
// Last-resort: lone surname match. Only accept if surname is unique enough
|
||||
// (i.e. only one contract has it).
|
||||
const candidates = contracts.filter((c) => lowerKey(c.owner.lastName) === last);
|
||||
if (candidates.length === 1) {
|
||||
match = candidates[0];
|
||||
matchedBy = 'owner_last_only';
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
unmatched.push({
|
||||
onboardingEntryId: rec.legacy.entryId,
|
||||
owner: rec.owner.fullName,
|
||||
dog: [rec.dog.name, rec.dog.surname].filter(Boolean).join(' '),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
matched++;
|
||||
if (!rec.owner.email && match.owner.email) {
|
||||
rec.owner.email = match.owner.email;
|
||||
backfilledEmail++;
|
||||
}
|
||||
if (!rec.owner.address && match.owner.address) {
|
||||
rec.owner.address = match.owner.address;
|
||||
rec.owner.addressParts = match.owner.addressParts;
|
||||
backfilledAddress++;
|
||||
}
|
||||
if (!rec.owner.phone && match.owner.phone) {
|
||||
rec.owner.phone = match.owner.phone;
|
||||
rec.owner.phoneRaw = match.owner.phoneRaw;
|
||||
backfilledPhone++;
|
||||
}
|
||||
rec.legacy.contractMatch = {
|
||||
entryId: match.legacy.entryId,
|
||||
matchedBy,
|
||||
signedOn: match.consent.signedOn,
|
||||
contractPdfUrl: match.legacy.pdfUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Write outputs -------------------------------------------------------------
|
||||
mkdirSync(dirname(CONTRACTS_OUT), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
CONTRACTS_OUT,
|
||||
JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
source: { file: 'goodwalk-contract-2026-05-20.csv', rows: data.length, columns: headers.length },
|
||||
records: contracts,
|
||||
}, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
onboardingPayload.enrichedAt = new Date().toISOString();
|
||||
onboardingPayload.notes = [
|
||||
...(onboardingPayload.notes ?? []),
|
||||
'Enriched from goodwalk-contract-2026-05-20.csv: owner email + postal address backfilled where a contract row matched.',
|
||||
];
|
||||
writeFileSync(ONBOARDING_JSON, JSON.stringify(onboardingPayload, null, 2) + '\n', 'utf8');
|
||||
|
||||
// -- Build a clients view keyed by email ---------------------------------------
|
||||
// This is the shape that maps most naturally to a Postgres `clients` table.
|
||||
const clientsByEmail = new Map();
|
||||
|
||||
function upsertClient(email, partial) {
|
||||
const key = (email ?? '').toLowerCase().trim();
|
||||
if (!key) return;
|
||||
const existing = clientsByEmail.get(key) ?? {
|
||||
email: key,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
phone: null,
|
||||
phoneRaw: null,
|
||||
address: null,
|
||||
addressParts: null,
|
||||
dogs: [],
|
||||
onboardingEntryIds: [],
|
||||
contractEntryIds: [],
|
||||
};
|
||||
for (const [k, v] of Object.entries(partial)) {
|
||||
if (v == null) continue;
|
||||
if (k === 'dogs') {
|
||||
for (const dog of v) {
|
||||
if (!existing.dogs.find((d) => (d.name ?? '').toLowerCase() === (dog.name ?? '').toLowerCase())) {
|
||||
existing.dogs.push(dog);
|
||||
}
|
||||
}
|
||||
} else if (k === 'onboardingEntryIds' || k === 'contractEntryIds') {
|
||||
for (const id of v) if (!existing[k].includes(id)) existing[k].push(id);
|
||||
} else if (existing[k] == null) {
|
||||
existing[k] = v;
|
||||
}
|
||||
}
|
||||
clientsByEmail.set(key, existing);
|
||||
}
|
||||
|
||||
for (const c of contracts) {
|
||||
if (!c.owner.email) continue;
|
||||
upsertClient(c.owner.email, {
|
||||
firstName: c.owner.firstName,
|
||||
lastName: c.owner.lastName,
|
||||
phone: c.owner.phone,
|
||||
phoneRaw: c.owner.phoneRaw,
|
||||
address: c.owner.address,
|
||||
addressParts: c.owner.addressParts,
|
||||
dogs: c.dog.fullName ? [{ name: c.dog.fullName, source: 'contract' }] : [],
|
||||
contractEntryIds: c.legacy.entryId ? [c.legacy.entryId] : [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const rec of onboardingPayload.records) {
|
||||
if (!rec.owner.email) continue;
|
||||
const dogName = [rec.dog.name, rec.dog.surname].filter(Boolean).join(' ');
|
||||
upsertClient(rec.owner.email, {
|
||||
firstName: rec.owner.firstName,
|
||||
lastName: rec.owner.lastName,
|
||||
phone: rec.owner.phone,
|
||||
phoneRaw: rec.owner.phoneRaw,
|
||||
address: rec.owner.address,
|
||||
addressParts: rec.owner.addressParts,
|
||||
dogs: dogName ? [{
|
||||
name: dogName,
|
||||
dateOfBirth: rec.dog.dateOfBirth,
|
||||
breed: rec.dog.breed,
|
||||
source: 'onboarding',
|
||||
}] : [],
|
||||
onboardingEntryIds: rec.legacy.entryId ? [rec.legacy.entryId] : [],
|
||||
});
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
CLIENTS_OUT,
|
||||
JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
note: 'Owner-email-keyed merged view. Maps 1:1 to a Postgres `clients` table; the `dogs` array maps to a `dogs` table with a clients_id FK.',
|
||||
clients: [...clientsByEmail.values()].sort((a, b) => a.email.localeCompare(b.email)),
|
||||
}, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// -- Summary -------------------------------------------------------------------
|
||||
console.log(JSON.stringify({
|
||||
contracts: contracts.length,
|
||||
contractsWithEmail: contracts.filter((c) => c.owner.email).length,
|
||||
onboardingRecords: onboardingPayload.records.length,
|
||||
matched,
|
||||
unmatched: unmatched.length,
|
||||
backfilledEmail,
|
||||
backfilledAddress,
|
||||
backfilledPhone,
|
||||
uniqueClientsByEmail: clientsByEmail.size,
|
||||
unmatchedSample: unmatched.slice(0, 10),
|
||||
outputs: { CONTRACTS_OUT, ONBOARDING_JSON, CLIENTS_OUT },
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
// Cleans the legacy Gravity Forms onboarding CSV export into a structured JSON
|
||||
// file ready for later import into Postgres.
|
||||
//
|
||||
// Input: dog-enrolment-form-2026-05-20.csv (repo root)
|
||||
// Output: data/legacy-onboarding.json
|
||||
//
|
||||
// Run: node scripts/clean-legacy-onboarding.mjs
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const INPUT = resolve(ROOT, 'dog-enrolment-form-2026-05-20.csv');
|
||||
const OUTPUT = resolve(ROOT, 'data/legacy-onboarding.json');
|
||||
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let cur = [];
|
||||
let val = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inQ) {
|
||||
if (c === '"') {
|
||||
if (text[i + 1] === '"') { val += '"'; i++; }
|
||||
else inQ = false;
|
||||
} else val += c;
|
||||
} else {
|
||||
if (c === '"') inQ = true;
|
||||
else if (c === ',') { cur.push(val); val = ''; }
|
||||
else if (c === '\n') { cur.push(val); rows.push(cur); cur = []; val = ''; }
|
||||
else if (c === '\r') { /* skip */ }
|
||||
else val += c;
|
||||
}
|
||||
}
|
||||
if (val.length || cur.length) { cur.push(val); rows.push(cur); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
const trimOrNull = (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return null;
|
||||
if (s.toLowerCase() === 'select') return null;
|
||||
return s;
|
||||
};
|
||||
|
||||
const yesNo = (v) => {
|
||||
const s = (v ?? '').trim().toLowerCase();
|
||||
if (!s || s === 'select') return null;
|
||||
if (s === 'yes') return true;
|
||||
if (s === 'no') return false;
|
||||
return null;
|
||||
};
|
||||
|
||||
// reactive-to-animals style: 'Yes'/'No'/'' or a free-text species ('Cats', 'Birds', 'Other').
|
||||
const reactiveField = (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return { reactive: null, detail: null };
|
||||
const lower = s.toLowerCase();
|
||||
if (lower === 'yes') return { reactive: true, detail: null };
|
||||
if (lower === 'no') return { reactive: false, detail: null };
|
||||
if (lower === 'select') return { reactive: null, detail: null };
|
||||
return { reactive: true, detail: s };
|
||||
};
|
||||
|
||||
const parseFloatOrNull = (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
// Best-effort NZ phone normalisation. Keeps the raw, adds an e164 when we can
|
||||
// confidently produce one. Doesn't try to invent missing data.
|
||||
function normalizePhone(raw) {
|
||||
const original = (raw ?? '').trim();
|
||||
if (!original) return { raw: null, e164: null };
|
||||
let digits = original.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) {
|
||||
return { raw: original, e164: '+' + digits.slice(1).replace(/\D/g, '') };
|
||||
}
|
||||
digits = digits.replace(/\D/g, '');
|
||||
if (!digits) return { raw: original, e164: null };
|
||||
if (digits.startsWith('64')) return { raw: original, e164: '+' + digits };
|
||||
if (digits.startsWith('0')) return { raw: original, e164: '+64' + digits.slice(1) };
|
||||
return { raw: original, e164: null };
|
||||
}
|
||||
|
||||
const isoDateOrNull = (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return null;
|
||||
// CSV already uses YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'
|
||||
return s;
|
||||
};
|
||||
|
||||
function emailGuess(ownerName, ownerSurname) {
|
||||
// Legacy CSV doesn't include owner email. Leaving null so the Postgres
|
||||
// import treats these as needing email-claim before they can sign in.
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = readFileSync(INPUT, 'utf8').replace(/^/, '');
|
||||
const rows = parseCsv(raw);
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1).filter((r) => r.some((cell) => (cell ?? '').trim() !== ''));
|
||||
|
||||
// Column index lookups (defensive in case header order shifts).
|
||||
const idx = Object.fromEntries(headers.map((h, i) => [h.trim(), i]));
|
||||
const col = (row, name) => row[idx[name]] ?? '';
|
||||
|
||||
const records = data.map((row) => {
|
||||
const hasFoodAllergy = yesNo(col(row, 'Does your dog have any food allergies?'));
|
||||
const foodAllergyDetail = trimOrNull(col(row, 'Specify'));
|
||||
// Two columns are labelled 'Specify'. The first is food, then env, then diet,
|
||||
// then medication — pick them by position.
|
||||
const specifyCols = headers
|
||||
.map((h, i) => ({ h, i }))
|
||||
.filter((x) => x.h.trim() === 'Specify')
|
||||
.map((x) => x.i);
|
||||
const [foodSpecifyIdx, envSpecifyIdx, dietSpecifyIdx, medSpecifyIdx] = specifyCols;
|
||||
|
||||
const phone = normalizePhone(col(row, 'Owner Contact'));
|
||||
const emergencyPhone = normalizePhone(col(row, 'Emergency Contact Number'));
|
||||
const vetPhone = normalizePhone(col(row, 'Vet Contact Number'));
|
||||
|
||||
const dogBirth = isoDateOrNull(col(row, "Dog's date of birth"));
|
||||
const signedOn = isoDateOrNull(col(row, 'Date'));
|
||||
const entryDate = isoDateOrNull(col(row, 'Entry Date'));
|
||||
const updatedDate = isoDateOrNull(col(row, 'Date Updated'));
|
||||
|
||||
const ownerFirst = trimOrNull(col(row, 'Owner Name'));
|
||||
const ownerLast = trimOrNull(col(row, 'Owner Surname'));
|
||||
|
||||
return {
|
||||
legacy: {
|
||||
entryId: trimOrNull(col(row, 'Entry Id')),
|
||||
entryDate,
|
||||
dateUpdated: updatedDate,
|
||||
createdByUserId: trimOrNull(col(row, 'Created By (User Id)')),
|
||||
sourceUrl: trimOrNull(col(row, 'Source Url')),
|
||||
signatureUrl: trimOrNull(col(row, 'Signature')),
|
||||
pdfUrl: trimOrNull(col(row, 'PDF: DogOnboardingForm')),
|
||||
userAgent: trimOrNull(col(row, 'User Agent')),
|
||||
userIp: trimOrNull(col(row, 'User IP')),
|
||||
},
|
||||
owner: {
|
||||
firstName: ownerFirst,
|
||||
lastName: ownerLast,
|
||||
fullName: [ownerFirst, ownerLast].filter(Boolean).join(' ') || null,
|
||||
email: emailGuess(ownerFirst, ownerLast),
|
||||
phone: phone.e164,
|
||||
phoneRaw: phone.raw,
|
||||
address: null, // not collected in legacy form
|
||||
},
|
||||
emergencyContact: {
|
||||
name: trimOrNull(col(row, 'Emergency Contact Name')),
|
||||
phone: emergencyPhone.e164,
|
||||
phoneRaw: emergencyPhone.raw,
|
||||
},
|
||||
dog: {
|
||||
name: trimOrNull(col(row, 'Dog Name')),
|
||||
surname: trimOrNull(col(row, 'Dog Surname')),
|
||||
dateOfBirth: dogBirth,
|
||||
breed: null, // not collected in legacy form
|
||||
},
|
||||
vet: {
|
||||
name: trimOrNull(col(row, 'Vet Name')),
|
||||
address: trimOrNull(col(row, 'Vet Address')),
|
||||
phone: vetPhone.e164,
|
||||
phoneRaw: vetPhone.raw,
|
||||
},
|
||||
health: {
|
||||
vaccinated: yesNo(col(row, 'Is your dog vaccinated?')),
|
||||
foodAllergy: {
|
||||
present: hasFoodAllergy,
|
||||
detail: hasFoodAllergy === true ? trimOrNull(row[foodSpecifyIdx]) : null,
|
||||
},
|
||||
environmentalAllergy: (() => {
|
||||
const present = yesNo(col(row, 'Does your dog have any environmental allergy?'));
|
||||
return {
|
||||
present,
|
||||
detail: present === true ? trimOrNull(row[envSpecifyIdx]) : null,
|
||||
};
|
||||
})(),
|
||||
specialDiet: (() => {
|
||||
const present = yesNo(col(row, 'Is your dog on a special diet?'));
|
||||
return {
|
||||
present,
|
||||
detail: present === true ? trimOrNull(row[dietSpecifyIdx]) : null,
|
||||
};
|
||||
})(),
|
||||
medication: (() => {
|
||||
const present = yesNo(col(row, 'Is your dog taking any medication that could put him at risk during a walk'));
|
||||
return {
|
||||
present,
|
||||
detail: present === true ? trimOrNull(row[medSpecifyIdx]) : null,
|
||||
};
|
||||
})(),
|
||||
},
|
||||
behaviour: {
|
||||
wellSocialised: yesNo(col(row, 'Is your dog well socialised?')),
|
||||
dogsInteractedWeekly: trimOrNull(col(row, 'How many dogs does your dog interact with weekly (excluding your family dogs)')),
|
||||
beachNotes: trimOrNull(col(row, 'Does your dog visit the beach?')),
|
||||
dogParkNotes: trimOrNull(col(row, 'Does your dog visit dog parks frequently - How many times a week?')),
|
||||
biteHistory: yesNo(col(row, 'Does your dog have a bite history?')),
|
||||
reactiveToDogs: yesNo(col(row, 'Is your dog reactive to other dogs?')),
|
||||
reactiveToAnimals: reactiveField(col(row, 'Is your dog reactive to other animals?')),
|
||||
reactiveToChildren: yesNo(col(row, 'Is your dog reactive to children?')),
|
||||
reactiveToPeople: yesNo(col(row, 'Is your dog reactive to other people?')),
|
||||
desexed: yesNo(col(row, 'Is your dog desexed?')),
|
||||
councilRegistered: yesNo(col(row, 'Is your dog registered?')),
|
||||
leashTrained: yesNo(col(row, 'Is your dog leash trained?')),
|
||||
recallRating: parseFloatOrNull(col(row, "Rate your dog's recall from 1 to 5, with one being the lowest score and 5 the highest.")),
|
||||
ranAwayBefore: yesNo(col(row, 'Has your dog ran away before?')),
|
||||
carBehaviour: trimOrNull(col(row, "Please describe your dog's behaviour in the car")),
|
||||
knownCommands: trimOrNull(col(row, 'List of commands your dog understands')),
|
||||
},
|
||||
misc: {
|
||||
additionalNotes: trimOrNull(col(row, "Anything else you'd like to let us know?")),
|
||||
socialMediaAccount: trimOrNull(col(row, 'Social Media Account Name')),
|
||||
howDidYouHear: trimOrNull(col(row, 'How did you hear about Goodwalk?')),
|
||||
referredBy: trimOrNull(col(row, "Person's name who reffered Goodwalk to you")),
|
||||
},
|
||||
declaration: {
|
||||
signedOn,
|
||||
signatureUrl: trimOrNull(col(row, 'Signature')),
|
||||
// Legacy submissions implicitly accepted T&Cs when submitted.
|
||||
termsAccepted: true,
|
||||
emergencyVetConsent: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUTPUT), { recursive: true });
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
source: {
|
||||
file: 'dog-enrolment-form-2026-05-20.csv',
|
||||
rows: data.length,
|
||||
columns: headers.length,
|
||||
},
|
||||
notes: [
|
||||
'Legacy Gravity Forms export. Owner email/address were not collected by the old form.',
|
||||
'Dog breed not collected by the old form.',
|
||||
'Phone numbers retained as raw + best-effort E.164 (+64). Verify before dialling.',
|
||||
"Yes/No fields parsed to booleans; 'Select' and blanks become null.",
|
||||
"'Reactive to other animals' values like Cats/Birds/Other map to { reactive: true, detail }.",
|
||||
],
|
||||
records,
|
||||
};
|
||||
|
||||
writeFileSync(OUTPUT, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
|
||||
// Print a small summary so the operator can sanity-check.
|
||||
const summary = {
|
||||
records: records.length,
|
||||
withOwnerPhone: records.filter((r) => r.owner.phone).length,
|
||||
withVetPhone: records.filter((r) => r.vet.phone).length,
|
||||
withEmergencyPhone: records.filter((r) => r.emergencyContact.phone).length,
|
||||
withSignature: records.filter((r) => r.declaration.signatureUrl).length,
|
||||
vaccinatedTrue: records.filter((r) => r.health.vaccinated === true).length,
|
||||
councilRegistered: records.filter((r) => r.behaviour.councilRegistered === true).length,
|
||||
output: OUTPUT,
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 362 KiB |
@@ -0,0 +1,98 @@
|
||||
"""Drive the homepage BookingWizard in a mobile viewport and check the success modal appears."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
BASE = "http://127.0.0.1:5180"
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 390, "height": 844},
|
||||
user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1",
|
||||
is_mobile=True,
|
||||
has_touch=True,
|
||||
device_scale_factor=3,
|
||||
)
|
||||
page = ctx.new_page()
|
||||
console_logs = []
|
||||
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
|
||||
page.on("pageerror", lambda err: console_logs.append(f"[pageerror] {err}"))
|
||||
|
||||
def handle_submit(route):
|
||||
print(f" intercepted /api/submit -> returning 200")
|
||||
route.fulfill(status=200, content_type="application/json", body='{"ok":true}')
|
||||
page.route("**/api/submit", handle_submit)
|
||||
|
||||
print("[1] Loading homepage…")
|
||||
page.goto(BASE + "/#newlead", wait_until="networkidle", timeout=30000)
|
||||
page.locator("#newlead").scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
print("[2] Step 1 — dog details")
|
||||
# Pet name (avoid honeypot which uses autocomplete="new-password")
|
||||
page.locator('.wiz-form input[placeholder*="Teddy"]').fill("Rex")
|
||||
# Pick first service
|
||||
page.locator('.wiz-service').first.click()
|
||||
# Message (textarea)
|
||||
page.locator('.wiz-form textarea').first.fill("Friendly dog, two years old, recall fine.")
|
||||
|
||||
# Continue
|
||||
cont = page.get_by_role("button", name="Continue")
|
||||
print(f" Continue button visible: {cont.is_visible()}")
|
||||
cont.click()
|
||||
page.wait_for_timeout(1200)
|
||||
page.locator("#newlead").screenshot(path="scripts/mobile-step2.png")
|
||||
errs = page.locator('.wiz-error').all()
|
||||
print(f" visible errors after Continue: {len(errs)}")
|
||||
for e in errs:
|
||||
try: print(f" -> {e.inner_text()}")
|
||||
except: pass
|
||||
print(f" inputs after Continue: {page.locator('.wiz-form input').count()}")
|
||||
for inp in page.locator('.wiz-form input').all():
|
||||
print(f" autocomplete={inp.get_attribute('autocomplete')!r} type={inp.get_attribute('type')!r} visible={inp.is_visible()}")
|
||||
|
||||
print("[3] Step 2 — owner details")
|
||||
page.locator('.wiz-form input[autocomplete="name"]').fill("Test User")
|
||||
page.locator('.wiz-form input[type="email"]').fill("test@example.com")
|
||||
page.locator('.wiz-form input[type="tel"]').fill("0211234567")
|
||||
page.locator('.wiz-form input[autocomplete="address-level2"]').fill("Mt Eden")
|
||||
|
||||
print("[4] Submit")
|
||||
page.get_by_role("button", name="Send my details").click()
|
||||
|
||||
print("[5] Waiting for success modal…")
|
||||
try:
|
||||
page.wait_for_selector('[role="dialog"]', timeout=8000, state="attached")
|
||||
print(" PASS: dialog attached")
|
||||
except Exception:
|
||||
print(" FAIL: dialog never attached")
|
||||
return 1
|
||||
|
||||
# Snapshot immediately
|
||||
page.screenshot(path="scripts/mobile-modal-immediate.png", full_page=False)
|
||||
c0 = page.locator('canvas').count()
|
||||
d0 = page.locator('[role="dialog"]').count()
|
||||
print(f" t=0ms: dialogs={d0} canvases={c0}")
|
||||
|
||||
for delay in (200, 500, 1000, 2000):
|
||||
page.wait_for_timeout(delay)
|
||||
d = page.locator('[role="dialog"]').count()
|
||||
c = page.locator('canvas').count()
|
||||
print(f" after +{delay}ms: dialogs={d} canvases={c}")
|
||||
if d == 0:
|
||||
print(" Modal vanished!")
|
||||
page.screenshot(path=f"scripts/mobile-vanished-{delay}.png", full_page=False)
|
||||
break
|
||||
|
||||
page.screenshot(path="scripts/mobile-modal-final.png", full_page=False)
|
||||
|
||||
print("\nConsole tail:")
|
||||
for line in console_logs[-12:]:
|
||||
print(f" {line}")
|
||||
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
sys.exit(run())
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Reproduce: testimonials cards blank after SPA navigation from another page."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
BASE = "https://www.goodwalk.co.nz"
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
for label, ctx_opts in [
|
||||
("mobile", {"viewport": {"width": 390, "height": 844}, "is_mobile": True, "has_touch": True,
|
||||
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1"}),
|
||||
]:
|
||||
print(f"\n=== {label} ===")
|
||||
ctx = browser.new_context(**ctx_opts)
|
||||
page = ctx.new_page()
|
||||
logs = []
|
||||
page.on("console", lambda m, L=logs: L.append(f"[{m.type}] {m.text}"))
|
||||
page.on("pageerror", lambda e, L=logs: L.append(f"[pageerror] {e}"))
|
||||
|
||||
print(" Loading homepage…")
|
||||
page.goto(BASE + "/", wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(800)
|
||||
all_links = page.locator('a[href*="testimonials"]').all()
|
||||
print(f" testimonials links found: {len(all_links)}")
|
||||
for a in all_links[:5]:
|
||||
print(f" href={a.get_attribute('href')!r} visible={a.is_visible()}")
|
||||
|
||||
# Try opening mobile menu (hamburger) then click testimonials
|
||||
burger = page.locator('button[aria-label*="menu" i], button.header-burger, [aria-label*="open" i]').first
|
||||
if burger.count() and burger.is_visible():
|
||||
burger.click()
|
||||
page.wait_for_timeout(400)
|
||||
# Click first now-visible testimonials link (SPA nav, no reload)
|
||||
visible_link = None
|
||||
for a in page.locator('a[href*="testimonials"]').all():
|
||||
if a.is_visible():
|
||||
visible_link = a
|
||||
break
|
||||
if visible_link is None:
|
||||
print(" no visible link to click")
|
||||
return
|
||||
print(" clicking testimonials link…")
|
||||
with page.expect_navigation(wait_until="load", timeout=5000) as nav_info:
|
||||
visible_link.click()
|
||||
print(f" nav type detected: same-document={nav_info.value is None}")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
page.wait_for_timeout(2500)
|
||||
print(f" url now: {page.url}")
|
||||
|
||||
section = page.locator(".testimonials-page-grid-section")
|
||||
print(f" section count: {section.count()}")
|
||||
if section.count():
|
||||
classes = section.first.get_attribute("class")
|
||||
print(f" section classes: {classes!r}")
|
||||
|
||||
cards = page.locator(".testimonials-page-card")
|
||||
print(f" cards count: {cards.count()}")
|
||||
if cards.count():
|
||||
first = cards.first
|
||||
computed = first.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity, transform: cs.transform }; }")
|
||||
print(f" first card computed: {computed}")
|
||||
last = cards.last
|
||||
last_comp = last.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity }; }")
|
||||
print(f" last card computed: {last_comp}")
|
||||
|
||||
page.screenshot(path=f"scripts/testimonials-spa-{label}.png", full_page=True)
|
||||
print(f" screenshot: scripts/testimonials-spa-{label}.png")
|
||||
|
||||
# Now scroll and recheck
|
||||
page.mouse.wheel(0, 600)
|
||||
page.wait_for_timeout(500)
|
||||
if cards.count():
|
||||
op_after = cards.first.evaluate("el => getComputedStyle(el).opacity")
|
||||
print(f" first card opacity after scroll 600px: {op_after}")
|
||||
page.screenshot(path=f"scripts/testimonials-spa-scrolled-{label}.png", full_page=True)
|
||||
|
||||
if logs:
|
||||
print(" console:")
|
||||
for l in logs[-12:]:
|
||||
print(f" {l}")
|
||||
ctx.close()
|
||||
browser.close()
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Load /testimonials and report what's actually visible."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
BASE = "http://127.0.0.1:5180"
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
for label, ctx_opts in [
|
||||
("desktop", {"viewport": {"width": 1280, "height": 900}}),
|
||||
("mobile", {"viewport": {"width": 390, "height": 844}, "is_mobile": True, "has_touch": True,
|
||||
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1"}),
|
||||
]:
|
||||
print(f"\n=== {label} ===")
|
||||
ctx = browser.new_context(**ctx_opts)
|
||||
page = ctx.new_page()
|
||||
logs = []
|
||||
page.on("console", lambda m, L=logs: L.append(f"[{m.type}] {m.text}"))
|
||||
page.on("pageerror", lambda e, L=logs: L.append(f"[pageerror] {e}"))
|
||||
|
||||
page.goto(BASE + "/testimonials", wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
section = page.locator(".testimonials-page-grid-section")
|
||||
print(f" section count: {section.count()}")
|
||||
classes = section.first.get_attribute("class") if section.count() else None
|
||||
print(f" section classes: {classes!r}")
|
||||
|
||||
cards = page.locator(".testimonials-page-card")
|
||||
print(f" cards count: {cards.count()}")
|
||||
|
||||
if cards.count():
|
||||
first = cards.first
|
||||
computed = first.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity, transform: cs.transform, display: cs.display, visibility: cs.visibility }; }")
|
||||
print(f" first card computed: {computed}")
|
||||
bb = first.bounding_box()
|
||||
print(f" first card bbox: {bb}")
|
||||
txt = first.inner_text()
|
||||
print(f" first card text: {txt[:200]!r}")
|
||||
|
||||
# Scroll into view and re-check
|
||||
if section.count():
|
||||
section.first.scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(800)
|
||||
classes2 = section.first.get_attribute("class")
|
||||
print(f" section classes after scroll: {classes2!r}")
|
||||
if cards.count():
|
||||
op2 = cards.first.evaluate("el => getComputedStyle(el).opacity")
|
||||
print(f" first card opacity after scroll: {op2}")
|
||||
|
||||
page.screenshot(path=f"scripts/testimonials-{label}.png", full_page=True)
|
||||
print(f" screenshot: scripts/testimonials-{label}.png")
|
||||
|
||||
if logs:
|
||||
print(" console:")
|
||||
for l in logs[-15:]:
|
||||
print(f" {l}")
|
||||
ctx.close()
|
||||
browser.close()
|
||||
|
||||
run()
|
||||
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |