feat: profile Lab services strip and dependency-light XLSX export

This commit is contained in:
SkateSpots 2026-10-30 00:10:29 +07:00
parent bf47d43674
commit 7a928dae6d
57 changed files with 326 additions and 118 deletions

105
package-lock.json generated
View file

@ -89,6 +89,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
@ -120,7 +121,6 @@
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"websocket-ts": "^2.2.1", "websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
@ -6006,15 +6006,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -7048,19 +7039,6 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
@ -7207,15 +7185,6 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -7329,18 +7298,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -8615,15 +8572,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -12266,18 +12214,6 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": { "node_modules/stackblur-canvas": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
@ -14127,24 +14063,6 @@
"bs58check": "^4.0.0" "bs58check": "^4.0.0"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -14246,27 +14164,6 @@
"node": ">=14.17.0" "node": ">=14.17.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -111,6 +111,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
@ -142,7 +143,6 @@
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"websocket-ts": "^2.2.1", "websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1002,10 +1002,10 @@ export function DeckCostCalculator() {
a.click(); a.click();
}; };
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand). // Multi-sheet XLSX workbook (lazy — our tiny local OOXML writer is only
// loaded when the user asks for the spreadsheet).
const exportXlsx = async () => { const exportXlsx = async () => {
const XLSX = await import('xlsx'); const { downloadXlsx } = await import('@/lib/xlsx');
const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4)); const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [ const summary: (string | number)[][] = [
[t('legacyMiscUi.exportTitle')], [t('legacyMiscUi.exportTitle')],
@ -1024,24 +1024,29 @@ export function DeckCostCalculator() {
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)], [t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)], [t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
]; ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), t('legacyMiscUi.exportXlsxSummary')); const equipment: (string | number)[][] = [
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
[t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]), ...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
]), t('legacyMiscUi.exportSectionEquipment')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const materials: (string | number)[][] = [
[t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]), ...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
]), t('legacyMiscUi.exportSectionMaterials')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const processes: (string | number)[][] = [
[t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]), ...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]),
]), t('legacyMiscUi.exportLabelProcesses')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const packaging: (string | number)[][] = [
[t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]), ...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
]), t('legacyMiscUi.exportSectionPackaging')); ];
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`); downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [
{ name: t('legacyMiscUi.exportXlsxSummary'), rows: summary },
{ name: t('legacyMiscUi.exportSectionEquipment'), rows: equipment },
{ name: t('legacyMiscUi.exportSectionMaterials'), rows: materials },
{ name: t('legacyMiscUi.exportLabelProcesses'), rows: processes },
{ name: t('legacyMiscUi.exportSectionPackaging'), rows: packaging },
]);
}; };
const copySummary = async () => { const copySummary = async () => {

View file

@ -216,3 +216,33 @@ export function useLabProfile(identifier: string, pubkey?: string) {
refetchOnMount: 'always', refetchOnMount: 'always',
}); });
} }
/** Lab provider profiles authored by one pubkey (profile-page services strip). */
export function useUserLabProfiles(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['user-lab-profiles', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden),
queryFn: async () => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(12_000);
const events = await querySkateContent(
[{
kinds: LAB_KINDS,
authors: [pubkey],
'#t': LAB_SELF_TAGS,
limit: 50,
}],
{ signal },
);
return events
.map(parseLabProfile)
.filter((profile): profile is LabProfile => !!profile)
.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 60_000,
});
}

View file

@ -986,6 +986,7 @@ const batch6 = {
nip05NotVerified: "NIP-05 no verificat amb aquesta clau", nip05NotVerified: "NIP-05 no verificat amb aquesta clau",
message: "Missatge", message: "Missatge",
eventsHosted: "Esdeveniments organitzats", eventsHosted: "Esdeveniments organitzats",
labServices: "Serveis de The Lab",
openToSponsorships: "Obert a patrocinis", openToSponsorships: "Obert a patrocinis",
forSponsors: "Per a patrocinadors i col·laboracions", forSponsors: "Per a patrocinadors i col·laboracions",
sponsorPitch: "Patrocini, col·laboració, foto/vídeo o esdeveniment? Comença una conversa.", sponsorPitch: "Patrocini, col·laboració, foto/vídeo o esdeveniment? Comença una conversa.",

View file

@ -33,6 +33,7 @@ const drift = {
}, },
labLink: { labLink: {
transport: 'Transport', transport: 'Transport',
designer: 'Roba',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ export const csBatch4 = {
nip05NotVerified: "NIP-05 neověřeno vůči tomuto klíči", nip05NotVerified: "NIP-05 neověřeno vůči tomuto klíči",
message: "Zpráva", message: "Zpráva",
eventsHosted: "Uspořádané akce", eventsHosted: "Uspořádané akce",
labServices: "Lab služby",
openToSponsorships: "Otevřeno sponzorům", openToSponsorships: "Otevřeno sponzorům",
forSponsors: "Pro sponzory a spolupráce", forSponsors: "Pro sponzory a spolupráce",
sponsorPitch: "Sponzorství, spolupráce, foto/video nebo akce? Začni konverzaci.", sponsorPitch: "Sponzorství, spolupráce, foto/video nebo akce? Začni konverzaci.",

View file

@ -75,6 +75,7 @@ const drift = {
"offerTransport": "Nabídnout dopravu", "offerTransport": "Nabídnout dopravu",
"labLink": { "labLink": {
"transport": "Doprava", "transport": "Doprava",
"designer": "Oblečení",
}, },
"detail": { "detail": {
"noun": { "noun": {

View file

@ -960,6 +960,7 @@ const deBatch4 = {
nip05NotVerified: "NIP-05 nicht gegen diesen Schlüssel verifiziert", nip05NotVerified: "NIP-05 nicht gegen diesen Schlüssel verifiziert",
message: "Nachricht", message: "Nachricht",
eventsHosted: "Organisierte Events", eventsHosted: "Organisierte Events",
labServices: "Lab-Dienste",
openToSponsorships: "Offen für Sponsoring", openToSponsorships: "Offen für Sponsoring",
forSponsors: "Für Sponsoren & Kooperationen", forSponsors: "Für Sponsoren & Kooperationen",
sponsorPitch: "Sponsoring, Kooperation, Foto/Video oder Event? Starte ein Gespräch.", sponsorPitch: "Sponsoring, Kooperation, Foto/Video oder Event? Starte ein Gespräch.",

View file

@ -77,6 +77,7 @@ const drift = {
offerTransport: "Transport anbieten", offerTransport: "Transport anbieten",
labLink: { labLink: {
transport: "Transport", transport: "Transport",
designer: "Kleidung",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const elBatch4 = {
nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί", nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί",
message: "Μήνυμα", message: "Μήνυμα",
eventsHosted: "Εκδηλώσεις που διοργάνωσες", eventsHosted: "Εκδηλώσεις που διοργάνωσες",
labServices: "Υπηρεσίες Lab",
openToSponsorships: "Ανοιχτός σε χορηγίες", openToSponsorships: "Ανοιχτός σε χορηγίες",
forSponsors: "Για χορηγούς και συνεργασίες", forSponsors: "Για χορηγούς και συνεργασίες",
sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.", sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.",

View file

@ -79,6 +79,7 @@ const drift = {
offerTransport: "Πρόσφερε μεταφορές", offerTransport: "Πρόσφερε μεταφορές",
labLink: { labLink: {
transport: "Μεταφορές", transport: "Μεταφορές",
designer: "Ρούχα",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -2691,6 +2691,7 @@ labUi: {
barber: 'Barber', barber: 'Barber',
organizer: 'Organizer', organizer: 'Organizer',
transport: 'Transport', transport: 'Transport',
designer: 'Clothing',
lab: 'Lab', lab: 'Lab',
linkedProfiles: 'Linked Lab Profiles', linkedProfiles: 'Linked Lab Profiles',
desc: 'Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional. When the Lab profile also links back to this crew, both show a green', desc: 'Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional. When the Lab profile also links back to this crew, both show a green',
@ -6793,6 +6794,7 @@ walletUi: {
nip05NotVerified: 'NIP-05 not verified against this key', nip05NotVerified: 'NIP-05 not verified against this key',
message: 'Message', message: 'Message',
eventsHosted: 'Events hosted', eventsHosted: 'Events hosted',
labServices: 'Lab services',
openToSponsorships: 'Open to sponsorships', openToSponsorships: 'Open to sponsorships',
forSponsors: 'For sponsors & collabs', forSponsors: 'For sponsors & collabs',
sponsorPitch: 'Sponsorship, collab, photo/video or event? Start a conversation.', sponsorPitch: 'Sponsorship, collab, photo/video or event? Start a conversation.',

View file

@ -2691,6 +2691,7 @@ labUi: {
barber: 'Barbero', barber: 'Barbero',
organizer: 'Organizador', organizer: 'Organizador',
transport: 'Transporte', transport: 'Transporte',
designer: 'Ropa',
lab: 'Lab', lab: 'Lab',
linkedProfiles: 'Perfiles del Lab vinculados', linkedProfiles: 'Perfiles del Lab vinculados',
desc: '¿También tienes un estudio, clases o un servicio de construcción? Vincula tus perfiles del Lab para que la gente pueda contratar a tu crew. Opcional. Cuando el perfil del Lab también enlaza de vuelta a este crew, ambos muestran una insignia', desc: '¿También tienes un estudio, clases o un servicio de construcción? Vincula tus perfiles del Lab para que la gente pueda contratar a tu crew. Opcional. Cuando el perfil del Lab también enlaza de vuelta a este crew, ambos muestran una insignia',
@ -6793,6 +6794,7 @@ walletUi: {
nip05NotVerified: 'NIP-05 no verificado con esta clave', nip05NotVerified: 'NIP-05 no verificado con esta clave',
message: 'Mensaje', message: 'Mensaje',
eventsHosted: 'Eventos organizados', eventsHosted: 'Eventos organizados',
labServices: 'Servicios de Lab',
openToSponsorships: 'Abierto a patrocinios', openToSponsorships: 'Abierto a patrocinios',
forSponsors: 'Para patrocinadores y colaboraciones', forSponsors: 'Para patrocinadores y colaboraciones',
sponsorPitch: '¿Patrocinio, colaboración, foto/vídeo o evento? Empieza una conversación.', sponsorPitch: '¿Patrocinio, colaboración, foto/vídeo o evento? Empieza una conversación.',

View file

@ -986,6 +986,7 @@ const batch6 = {
nip05NotVerified: "NIP-05 ez da gako honekin egiaztatu", nip05NotVerified: "NIP-05 ez da gako honekin egiaztatu",
message: "Mezua", message: "Mezua",
eventsHosted: "Antolatutako ekitaldiak", eventsHosted: "Antolatutako ekitaldiak",
labServices: "Lab zerbitzuak",
openToSponsorships: "Babesleentzako irekita", openToSponsorships: "Babesleentzako irekita",
forSponsors: "Babesleentzat eta lankidetzetarako", forSponsors: "Babesleentzat eta lankidetzetarako",
sponsorPitch: "Babesa, lankidetza, argazki/bideoa edo ekitaldia? Hasi solasaldia.", sponsorPitch: "Babesa, lankidetza, argazki/bideoa edo ekitaldia? Hasi solasaldia.",

View file

@ -33,6 +33,7 @@ const drift = {
}, },
labLink: { labLink: {
transport: 'Garraioa', transport: 'Garraioa',
designer: 'Arropa',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const frBatch4 = {
nip05NotVerified: "NIP-05 non vérifié pour cette clé", nip05NotVerified: "NIP-05 non vérifié pour cette clé",
message: "Message", message: "Message",
eventsHosted: "Événements organisés", eventsHosted: "Événements organisés",
labServices: "Services Lab",
openToSponsorships: "Ouvert au sponsoring", openToSponsorships: "Ouvert au sponsoring",
forSponsors: "Pour les sponsors et collaborations", forSponsors: "Pour les sponsors et collaborations",
sponsorPitch: "Sponsoring, collaboration, photo/vidéo ou événement ? Lance la conversation.", sponsorPitch: "Sponsoring, collaboration, photo/vidéo ou événement ? Lance la conversation.",

View file

@ -77,6 +77,7 @@ const drift = {
offerTransport: "Proposer un transport", offerTransport: "Proposer un transport",
labLink: { labLink: {
transport: "Transport", transport: "Transport",
designer: "Vêtements",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -986,6 +986,7 @@ const batch6 = {
"nip05NotVerified": "NIP-05 non verificado con esta chave", "nip05NotVerified": "NIP-05 non verificado con esta chave",
"message": "Mensaxe", "message": "Mensaxe",
"eventsHosted": "Eventos organizados", "eventsHosted": "Eventos organizados",
"labServices": "Servizos do Lab",
"openToSponsorships": "Aberto a patrocinios", "openToSponsorships": "Aberto a patrocinios",
"forSponsors": "Para patrocinadores e colaboracións", "forSponsors": "Para patrocinadores e colaboracións",
"sponsorPitch": "Patrocinio, colaboración, foto/vídeo ou evento? Comeza unha conversa.", "sponsorPitch": "Patrocinio, colaboración, foto/vídeo ou evento? Comeza unha conversa.",

View file

@ -33,6 +33,7 @@ const drift = {
}, },
labLink: { labLink: {
transport: 'Transporte', transport: 'Transporte',
designer: 'Roupa',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -986,6 +986,7 @@ const batch6 = {
"nip05NotVerified": "NIP-05 इस कुंजी से सत्यापित नहीं", "nip05NotVerified": "NIP-05 इस कुंजी से सत्यापित नहीं",
"message": "संदेश", "message": "संदेश",
"eventsHosted": "आयोजित कार्यक्रम", "eventsHosted": "आयोजित कार्यक्रम",
"labServices": "Lab सेवाएँ",
"openToSponsorships": "प्रायोजन के लिए उपलब्ध", "openToSponsorships": "प्रायोजन के लिए उपलब्ध",
"forSponsors": "प्रायोजकों और सहयोगों के लिए", "forSponsors": "प्रायोजकों और सहयोगों के लिए",
"sponsorPitch": "प्रायोजन, सहयोग, फ़ोटो/वीडियो या कार्यक्रम? बातचीत शुरू करें।", "sponsorPitch": "प्रायोजन, सहयोग, फ़ोटो/वीडियो या कार्यक्रम? बातचीत शुरू करें।",

View file

@ -28,6 +28,7 @@ const drift = {
}, },
labLink: { labLink: {
transport: 'परिवहन', transport: 'परिवहन',
designer: 'कपड़े',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -966,6 +966,7 @@ const idBatch4 = {
nip05NotVerified: 'NIP-05 tidak terverifikasi terhadap kunci ini', nip05NotVerified: 'NIP-05 tidak terverifikasi terhadap kunci ini',
message: 'Pesan', message: 'Pesan',
eventsHosted: 'Acara yang diselenggarakan', eventsHosted: 'Acara yang diselenggarakan',
labServices: 'Layanan Lab',
openToSponsorships: 'Terbuka untuk sponsor', openToSponsorships: 'Terbuka untuk sponsor',
forSponsors: 'Untuk sponsor & kolaborasi', forSponsors: 'Untuk sponsor & kolaborasi',
sponsorPitch: 'Sponsorship, kolaborasi, foto/video, atau acara? Mulai percakapan.', sponsorPitch: 'Sponsorship, kolaborasi, foto/video, atau acara? Mulai percakapan.',

View file

@ -71,6 +71,7 @@ const driftStrings = {
offerTransport: 'Tawarkan transportasi', offerTransport: 'Tawarkan transportasi',
labLink: { labLink: {
transport: 'Transportasi', transport: 'Transportasi',
designer: 'Pakaian',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const itBatch4 = {
nip05NotVerified: "NIP-05 non verificato con questa chiave", nip05NotVerified: "NIP-05 non verificato con questa chiave",
message: "Messaggio", message: "Messaggio",
eventsHosted: "Eventi organizzati", eventsHosted: "Eventi organizzati",
labServices: "Servizi Lab",
openToSponsorships: "Aperto a sponsorizzazioni", openToSponsorships: "Aperto a sponsorizzazioni",
forSponsors: "Per sponsor e collaborazioni", forSponsors: "Per sponsor e collaborazioni",
sponsorPitch: "Sponsorizzazione, collaborazione, foto/video o evento? Inizia una conversazione.", sponsorPitch: "Sponsorizzazione, collaborazione, foto/video o evento? Inizia una conversazione.",

View file

@ -77,6 +77,7 @@ const drift = {
offerTransport: "Offri trasporto", offerTransport: "Offri trasporto",
labLink: { labLink: {
transport: "Trasporto", transport: "Trasporto",
designer: "Abbigliamento",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -969,6 +969,7 @@ const jaBatch7 = {
nip05NotVerified: 'NIP-05がこの鍵で認証されていません', nip05NotVerified: 'NIP-05がこの鍵で認証されていません',
message: 'メッセージ', message: 'メッセージ',
eventsHosted: '主催イベント', eventsHosted: '主催イベント',
labServices: 'Labサービス',
openToSponsorships: 'スポンサー募集中', openToSponsorships: 'スポンサー募集中',
forSponsors: 'スポンサー・コラボ募集', forSponsors: 'スポンサー・コラボ募集',
sponsorPitch: 'スポンサー、コラボ、写真/動画、イベントの依頼はありませんか?まずはご相談ください。', sponsorPitch: 'スポンサー、コラボ、写真/動画、イベントの依頼はありませんか?まずはご相談ください。',

View file

@ -87,6 +87,7 @@ const drift: Dict = {
offerTransport: '輸送を提供', offerTransport: '輸送を提供',
labLink: { labLink: {
transport: '輸送', transport: '輸送',
designer: '服',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -968,6 +968,7 @@ const koBatch4 = {
nip05NotVerified: '이 키에 대해 NIP-05가 인증되지 않음', nip05NotVerified: '이 키에 대해 NIP-05가 인증되지 않음',
message: '메시지', message: '메시지',
eventsHosted: '주최한 이벤트', eventsHosted: '주최한 이벤트',
labServices: 'Lab 서비스',
openToSponsorships: '스폰서 모집 중', openToSponsorships: '스폰서 모집 중',
forSponsors: '스폰서 및 협업 문의', forSponsors: '스폰서 및 협업 문의',
sponsorPitch: '스폰서십, 협업, 사진/영상 또는 이벤트? 대화를 시작해 보세요.', sponsorPitch: '스폰서십, 협업, 사진/영상 또는 이벤트? 대화를 시작해 보세요.',

View file

@ -87,6 +87,7 @@ const drift: Dict = {
offerTransport: '운송 제공', offerTransport: '운송 제공',
labLink: { labLink: {
transport: '운송', transport: '운송',
designer: '의류',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -968,6 +968,7 @@ const nlBatch4 = {
nip05NotVerified: 'NIP-05 niet geverifieerd tegen deze sleutel', nip05NotVerified: 'NIP-05 niet geverifieerd tegen deze sleutel',
message: 'Bericht', message: 'Bericht',
eventsHosted: 'Georganiseerde evenementen', eventsHosted: 'Georganiseerde evenementen',
labServices: 'Lab-diensten',
openToSponsorships: 'Open voor sponsoring', openToSponsorships: 'Open voor sponsoring',
forSponsors: 'Voor sponsors & samenwerkingen', forSponsors: 'Voor sponsors & samenwerkingen',
sponsorPitch: 'Sponsoring, samenwerking, foto/video of evenement? Start een gesprek.', sponsorPitch: 'Sponsoring, samenwerking, foto/video of evenement? Start een gesprek.',

View file

@ -75,6 +75,7 @@ const drift = {
"offerTransport": "Transport aanbieden", "offerTransport": "Transport aanbieden",
"labLink": { "labLink": {
"transport": "Transport", "transport": "Transport",
"designer": "Kleding",
}, },
"detail": { "detail": {
"noun": { "noun": {

View file

@ -960,6 +960,7 @@ const plBatch4 = {
nip05NotVerified: "NIP-05 niezweryfikowany względem tego klucza", nip05NotVerified: "NIP-05 niezweryfikowany względem tego klucza",
message: "Wiadomość", message: "Wiadomość",
eventsHosted: "Organizowane wydarzenia", eventsHosted: "Organizowane wydarzenia",
labServices: "Usługi w The Lab",
openToSponsorships: "Otwarty na sponsoring", openToSponsorships: "Otwarty na sponsoring",
forSponsors: "Dla sponsorów i współprac", forSponsors: "Dla sponsorów i współprac",
sponsorPitch: "Sponsoring, współpraca, foto/wideo lub wydarzenie? Rozpocznij rozmowę.", sponsorPitch: "Sponsoring, współpraca, foto/wideo lub wydarzenie? Rozpocznij rozmowę.",

View file

@ -75,6 +75,7 @@ const drift = {
"offerTransport": "Oferuj transport", "offerTransport": "Oferuj transport",
"labLink": { "labLink": {
"transport": "Transport", "transport": "Transport",
"designer": "Odzież",
}, },
"detail": { "detail": {
"noun": { "noun": {

View file

@ -1095,6 +1095,7 @@ const ptBatch4 = {
nip05NotVerified: 'NIP-05 não verificado para esta chave', nip05NotVerified: 'NIP-05 não verificado para esta chave',
message: 'Mensagem', message: 'Mensagem',
eventsHosted: 'Eventos organizados', eventsHosted: 'Eventos organizados',
labServices: 'Serviços do Lab',
openToSponsorships: 'Aberto a patrocínios', openToSponsorships: 'Aberto a patrocínios',
forSponsors: 'Para patrocinadores e colaborações', forSponsors: 'Para patrocinadores e colaborações',
sponsorPitch: 'Patrocínio, colaboração, foto/vídeo ou evento? Comece uma conversa.', sponsorPitch: 'Patrocínio, colaboração, foto/vídeo ou evento? Comece uma conversa.',

View file

@ -77,6 +77,7 @@ const drift = {
offerTransport: "Oferecer transporte", offerTransport: "Oferecer transporte",
labLink: { labLink: {
transport: "Transporte", transport: "Transporte",
designer: "Roupas",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const roBatch4 = {
nip05NotVerified: 'NIP-05 neverificat pentru această cheie', nip05NotVerified: 'NIP-05 neverificat pentru această cheie',
message: 'Mesaj', message: 'Mesaj',
eventsHosted: 'Evenimente organizate', eventsHosted: 'Evenimente organizate',
labServices: 'Servicii Lab',
openToSponsorships: 'Deschis la sponsorizări', openToSponsorships: 'Deschis la sponsorizări',
forSponsors: 'Pentru sponsori și colaborări', forSponsors: 'Pentru sponsori și colaborări',
sponsorPitch: 'Sponsorizare, colaborare, foto/video sau eveniment? Începe o conversație.', sponsorPitch: 'Sponsorizare, colaborare, foto/video sau eveniment? Începe o conversație.',

View file

@ -75,6 +75,7 @@ const drift = {
"offerTransport": "Oferă transport", "offerTransport": "Oferă transport",
"labLink": { "labLink": {
"transport": "Transport", "transport": "Transport",
"designer": "Îmbrăcăminte",
}, },
"detail": { "detail": {
"noun": { "noun": {

View file

@ -960,6 +960,7 @@ const ruBatch4 = {
nip05NotVerified: 'NIP-05 не подтверждён для этого ключа', nip05NotVerified: 'NIP-05 не подтверждён для этого ключа',
message: 'Сообщение', message: 'Сообщение',
eventsHosted: 'Проведённые мероприятия', eventsHosted: 'Проведённые мероприятия',
labServices: 'Услуги The Lab',
openToSponsorships: 'Открыт для спонсорства', openToSponsorships: 'Открыт для спонсорства',
forSponsors: 'Для спонсоров и коллабораций', forSponsors: 'Для спонсоров и коллабораций',
sponsorPitch: 'Спонсорство, коллаборация, фото/видео или мероприятие? Начните разговор.', sponsorPitch: 'Спонсорство, коллаборация, фото/видео или мероприятие? Начните разговор.',

View file

@ -79,6 +79,7 @@ const drift = {
offerTransport: "Предложить транспорт", offerTransport: "Предложить транспорт",
labLink: { labLink: {
transport: "Транспорт", transport: "Транспорт",
designer: "Одежда",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const svBatch4 = {
nip05NotVerified: "NIP-05 inte verifierad mot den här nyckeln", nip05NotVerified: "NIP-05 inte verifierad mot den här nyckeln",
message: "Meddelande", message: "Meddelande",
eventsHosted: "Arrangerade evenemang", eventsHosted: "Arrangerade evenemang",
labServices: "Lab-tjänster",
openToSponsorships: "Öppen för sponsring", openToSponsorships: "Öppen för sponsring",
forSponsors: "För sponsorer & samarbeten", forSponsors: "För sponsorer & samarbeten",
sponsorPitch: "Sponsring, samarbete, foto/video eller evenemang? Starta ett samtal.", sponsorPitch: "Sponsring, samarbete, foto/video eller evenemang? Starta ett samtal.",

View file

@ -79,6 +79,7 @@ const drift = {
offerTransport: "Erbjud transport", offerTransport: "Erbjud transport",
labLink: { labLink: {
transport: "Transport", transport: "Transport",
designer: "Kläder",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -1069,6 +1069,7 @@ const thBatch9 = {
nip05NotVerified: 'NIP-05 ยังไม่ได้รับการยืนยันกับกุญแจนี้', nip05NotVerified: 'NIP-05 ยังไม่ได้รับการยืนยันกับกุญแจนี้',
message: 'ข้อความ', message: 'ข้อความ',
eventsHosted: 'กิจกรรมที่จัดขึ้น', eventsHosted: 'กิจกรรมที่จัดขึ้น',
labServices: 'บริการ Lab',
openToSponsorships: 'เปิดรับสปอนเซอร์', openToSponsorships: 'เปิดรับสปอนเซอร์',
forSponsors: 'สำหรับสปอนเซอร์และความร่วมมือ', forSponsors: 'สำหรับสปอนเซอร์และความร่วมมือ',
sponsorPitch: 'การสนับสนุน ความร่วมมือ ภาพ/วิดีโอ หรืออีเวนต์? เริ่มคุยกันได้เลย', sponsorPitch: 'การสนับสนุน ความร่วมมือ ภาพ/วิดีโอ หรืออีเวนต์? เริ่มคุยกันได้เลย',

View file

@ -71,6 +71,7 @@ const driftStrings = {
offerTransport: 'เสนอบริการขนส่ง', offerTransport: 'เสนอบริการขนส่ง',
labLink: { labLink: {
transport: 'ขนส่ง', transport: 'ขนส่ง',
designer: 'เสื้อผ้า',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const trBatch4 = {
nip05NotVerified: "NIP-05 bu anahtara karşı doğrulanmadı", nip05NotVerified: "NIP-05 bu anahtara karşı doğrulanmadı",
message: "Mesaj", message: "Mesaj",
eventsHosted: "Düzenlenen etkinlikler", eventsHosted: "Düzenlenen etkinlikler",
labServices: "The Lab hizmetleri",
openToSponsorships: "Sponsorluğa açık", openToSponsorships: "Sponsorluğa açık",
forSponsors: "Sponsorlar ve iş birlikleri için", forSponsors: "Sponsorlar ve iş birlikleri için",
sponsorPitch: "Sponsorluk, iş birliği, fotoğraf/video veya etkinlik mi? Hemen konuşmaya başla.", sponsorPitch: "Sponsorluk, iş birliği, fotoğraf/video veya etkinlik mi? Hemen konuşmaya başla.",

View file

@ -71,6 +71,7 @@ const driftStrings = {
offerTransport: 'Ulaşım sun', offerTransport: 'Ulaşım sun',
labLink: { labLink: {
transport: 'Ulaşım', transport: 'Ulaşım',
designer: 'Giyim',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -960,6 +960,7 @@ const ukBatch4 = {
nip05NotVerified: "NIP-05 не підтверджено для цього ключа", nip05NotVerified: "NIP-05 не підтверджено для цього ключа",
message: "Повідомлення", message: "Повідомлення",
eventsHosted: "Проведені події", eventsHosted: "Проведені події",
labServices: "Послуги Lab",
openToSponsorships: "Відкритий до спонсорства", openToSponsorships: "Відкритий до спонсорства",
forSponsors: "Для спонсорів і колаборацій", forSponsors: "Для спонсорів і колаборацій",
sponsorPitch: "Спонсорство, колаборація, фото/відео чи подія? Почніть розмову.", sponsorPitch: "Спонсорство, колаборація, фото/відео чи подія? Почніть розмову.",

View file

@ -79,6 +79,7 @@ const drift = {
offerTransport: "Запропонувати транспорт", offerTransport: "Запропонувати транспорт",
labLink: { labLink: {
transport: "Транспорт", transport: "Транспорт",
designer: "Одяг",
}, },
detail: { detail: {
noun: { noun: {

View file

@ -968,6 +968,7 @@ const viBatch4 = {
nip05NotVerified: 'NIP-05 chưa được xác minh với khóa này', nip05NotVerified: 'NIP-05 chưa được xác minh với khóa này',
message: 'Nhắn tin', message: 'Nhắn tin',
eventsHosted: 'Sự kiện đã tổ chức', eventsHosted: 'Sự kiện đã tổ chức',
labServices: 'Dịch vụ Lab',
openToSponsorships: 'Sẵn sàng nhận tài trợ', openToSponsorships: 'Sẵn sàng nhận tài trợ',
forSponsors: 'Dành cho nhà tài trợ & hợp tác', forSponsors: 'Dành cho nhà tài trợ & hợp tác',
sponsorPitch: 'Tài trợ, hợp tác, ảnh/video hay sự kiện? Bắt đầu trò chuyện nhé.', sponsorPitch: 'Tài trợ, hợp tác, ảnh/video hay sự kiện? Bắt đầu trò chuyện nhé.',

View file

@ -71,6 +71,7 @@ const driftStrings = {
offerTransport: 'Cung cấp vận chuyển', offerTransport: 'Cung cấp vận chuyển',
labLink: { labLink: {
transport: 'Vận chuyển', transport: 'Vận chuyển',
designer: 'Quần áo',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -965,6 +965,7 @@ const zhCNBatch4 = {
nip05NotVerified: "NIP-05 与此密钥未通过验证", nip05NotVerified: "NIP-05 与此密钥未通过验证",
message: "私信", message: "私信",
eventsHosted: "主办的活动", eventsHosted: "主办的活动",
labServices: "Lab 服务",
openToSponsorships: "开放赞助合作", openToSponsorships: "开放赞助合作",
forSponsors: "赞助与合作", forSponsors: "赞助与合作",
sponsorPitch: "赞助、合作、拍摄照片/视频还是办活动?来聊聊吧。", sponsorPitch: "赞助、合作、拍摄照片/视频还是办活动?来聊聊吧。",

View file

@ -87,6 +87,7 @@ const drift: Dict = {
offerTransport: '提供交通服务', offerTransport: '提供交通服务',
labLink: { labLink: {
transport: '交通', transport: '交通',
designer: '服装',
}, },
detail: { detail: {
noun: { noun: {

View file

@ -965,6 +965,7 @@ const zhTWBatch4 = {
nip05NotVerified: "NIP-05 與此金鑰未透過驗證", nip05NotVerified: "NIP-05 與此金鑰未透過驗證",
message: "私信", message: "私信",
eventsHosted: "主辦的活動", eventsHosted: "主辦的活動",
labServices: "Lab 服務",
openToSponsorships: "開放贊助合作", openToSponsorships: "開放贊助合作",
forSponsors: "贊助與合作", forSponsors: "贊助與合作",
sponsorPitch: "贊助、合作、拍攝照片/影片還是辦活動?來聊聊吧。", sponsorPitch: "贊助、合作、拍攝照片/影片還是辦活動?來聊聊吧。",

View file

@ -87,6 +87,7 @@ const drift: Dict = {
offerTransport: '提供交通服務', offerTransport: '提供交通服務',
labLink: { labLink: {
transport: '交通', transport: '交通',
designer: '服飾',
}, },
detail: { detail: {
noun: { noun: {

53
src/lib/transport.test.ts Normal file
View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_TRANSPORT_KIND, parseSkateTransport } from './types';
function transportEvent(overrides: Partial<NostrEvent> = {}): NostrEvent {
return {
id: 'b'.repeat(64),
pubkey: 'a'.repeat(64),
created_at: 1_700_000_000,
kind: SKATE_TRANSPORT_KIND,
content: 'Airport runs and event gear moves.',
sig: 'c'.repeat(128),
tags: [
['d', 'transport-profile'],
['title', 'BCN Skate Transfers'],
['t', 'skatetransport'],
['t', 'van'],
['t', 'people'],
['t', 'gear'],
['t', 'cargo'],
['t', 'solo'],
['t', 'regional'],
['location', 'Barcelona'],
['capacity', '4 passengers / 2 boards'],
['coverage', 'Barcelona ↔ Perpignan'],
['accessibility', 'wheelchair space'],
],
...overrides,
};
}
describe('parseSkateTransport', () => {
it('parses vehicle, service, capability, capacity and coverage tags', () => {
const profile = parseSkateTransport(transportEvent());
expect(profile).not.toBeNull();
expect(profile!.identifier).toBe('transport-profile');
expect(profile!.title).toBe('BCN Skate Transfers');
expect(profile!.skills).toEqual(
expect.arrayContaining(['van', 'people', 'gear', 'cargo', 'solo', 'regional']),
);
expect(profile!.skills).not.toContain('skatetransport');
expect(profile!.location).toBe('Barcelona');
});
it('rejects a transport event without a d or title', () => {
expect(parseSkateTransport(transportEvent({ tags: [['t', 'skatetransport']] }))).toBeNull();
expect(parseSkateTransport(transportEvent({ tags: [['d', 'x'], ['t', 'skatetransport']] }))).toBeNull();
});
it('rejects other Lab kinds', () => {
expect(parseSkateTransport(transportEvent({ kind: 35015 }))).toBeNull();
});
});

46
src/lib/xlsx.test.ts Normal file
View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { strFromU8, unzipSync } from 'fflate';
import { buildXlsx } from './xlsx';
function entries(bytes: Uint8Array): Record<string, string> {
const files = unzipSync(bytes);
return Object.fromEntries(Object.entries(files).map(([name, data]) => [name, strFromU8(data)]));
}
describe('buildXlsx', () => {
it('builds the required OOXML parts and escapes text', () => {
const files = entries(buildXlsx([
{ name: 'Summary/Test', rows: [['Name', '<value>'], ['Deck', 42.5]] },
]));
expect(Object.keys(files)).toEqual(expect.arrayContaining([
'[Content_Types].xml',
'_rels/.rels',
'xl/workbook.xml',
'xl/_rels/workbook.xml.rels',
'xl/worksheets/sheet1.xml',
]));
expect(files['xl/workbook.xml']).toContain('name="Summary Test"');
expect(files['xl/workbook.xml']).not.toContain('Summary/Test');
expect(files['xl/worksheets/sheet1.xml']).toContain('&lt;value&gt;');
expect(files['xl/worksheets/sheet1.xml']).toContain('<v>42.5</v>');
expect(files['xl/worksheets/sheet1.xml']).toContain('r="A2"');
});
it('keeps sheet names unique and within Excel’s 31-character limit', () => {
const long = 'A very long spreadsheet sheet name that exceeds the limit';
const files = entries(buildXlsx([
{ name: long, rows: [['one']] },
{ name: long, rows: [['two']] },
]));
const workbook = files['xl/workbook.xml'];
expect(workbook).toContain('name="A very long spreadsheet sheet n"');
expect(workbook).toContain('name="A very long spreadsheet she (2)"');
expect(workbook).not.toContain(long);
});
it('falls back to a sheet when the caller passes none', () => {
const files = entries(buildXlsx([]));
expect(files['xl/workbook.xml']).toContain('name="Sheet 1"');
});
});

125
src/lib/xlsx.ts Normal file
View file

@ -0,0 +1,125 @@
/**
* Tiny, dependency-light XLSX writer for the few spreadsheet exports in the app.
*
* The app only ever WRITES XLSX (the deck-cost sheet); it never parses
* untrusted spreadsheets. Keeping a local OOXML writer avoids carrying the
* vulnerable `xlsx` parser for a write-only feature, and keeps the export
* chunk tiny.
*
* Produces a standards-compliant single-workbook XLSX with inline strings.
* Deliberately no styles/formulas — the exported data is plain rows.
*/
import { zipSync, strToU8 } from 'fflate';
export type XlsxCell = string | number | null | undefined;
export type XlsxSheet = { name: string; rows: XlsxCell[][] };
const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function safeSheetName(name: string, index: number, used: Set<string>): string {
const base = name.replace(/[\\/?*[\]:]/g, ' ').trim().slice(0, 31) || `Sheet ${index + 1}`;
let candidate = base;
let n = 2;
while (used.has(candidate.toLowerCase())) {
const suffix = ` (${n++})`;
candidate = `${base.slice(0, Math.max(1, 31 - suffix.length))}${suffix}`;
}
used.add(candidate.toLowerCase());
return candidate;
}
function columnName(index: number): string {
let n = index + 1;
let out = '';
while (n > 0) {
const rem = (n - 1) % 26;
out = String.fromCharCode(65 + rem) + out;
n = Math.floor((n - 1) / 26);
}
return out;
}
function cellXml(value: XlsxCell, ref: string): string {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'number' && Number.isFinite(value)) {
return `<c r="${ref}"><v>${value}</v></c>`;
}
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(String(value))}</t></is></c>`;
}
function sheetXml(rows: XlsxCell[][]): string {
const body = rows
.map((row, rowIndex) => {
const cells = row
.map((value, colIndex) => cellXml(value, `${columnName(colIndex)}${rowIndex + 1}`))
.join('');
return cells ? `<row r="${rowIndex + 1}">${cells}</row>` : '';
})
.join('');
return `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
}
/** Build a valid XLSX byte array. Exported for tests and non-browser callers. */
export function buildXlsx(sheets: XlsxSheet[]): Uint8Array {
const safeSheets = (sheets.length ? sheets : [{ name: 'Sheet 1', rows: [] }]).map((sheet, index) => ({
...sheet,
_safeName: '',
_index: index,
}));
const used = new Set<string>();
for (const sheet of safeSheets) sheet._safeName = safeSheetName(sheet.name, sheet._index, used);
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
safeSheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('') +
'</Types>';
const rels = `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const workbook = `${XML_HEADER}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
safeSheets.map((sheet, i) => `<sheet name="${escapeXml(sheet._safeName)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('') +
'</sheets></workbook>';
const workbookRels = `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
safeSheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('') +
'</Relationships>';
const files: Record<string, Uint8Array> = {
'[Content_Types].xml': strToU8(contentTypes),
'_rels/.rels': strToU8(rels),
'xl/workbook.xml': strToU8(workbook),
'xl/_rels/workbook.xml.rels': strToU8(workbookRels),
};
safeSheets.forEach((sheet, i) => {
files[`xl/worksheets/sheet${i + 1}.xml`] = strToU8(sheetXml(sheet.rows));
});
return zipSync(files, { level: 6 });
}
/** Trigger a browser download of a built workbook. */
export function downloadXlsx(filename: string, sheets: XlsxSheet[]): void {
const bytes = buildXlsx(sheets);
const blob = new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}