diff --git a/env.d.ts b/env.d.ts index 11f02fe..ad3afc1 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + /** Hex- or bech32-encoded npub of the inquiry recipient. */ + readonly VITE_OWNER_NPUB?: string + /** Comma-separated wss:// relay list; falls back to a default set if unset. */ + readonly VITE_NOSTR_RELAYS?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/package.json b/package.json index 8c678b7..1486224 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@vueuse/core": "^14.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "nostr-tools": "^2.23.5", "pinia": "^3.0.4", "reka-ui": "^2.9.8", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b159c..f90a373 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + nostr-tools: + specifier: ^2.23.5 + version: 2.23.5(typescript@6.0.3) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) @@ -257,6 +260,18 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -374,6 +389,15 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1126,6 +1150,17 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nostr-tools@2.23.5: + resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -1667,6 +1702,14 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1734,6 +1777,19 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -2477,6 +2533,20 @@ snapshots: natural-compare@1.4.0: {} + nostr-tools@2.23.5(typescript@6.0.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 6.0.3 + + nostr-wasm@0.1.0: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 diff --git a/src/features/nostr/store.ts b/src/features/nostr/store.ts new file mode 100644 index 0000000..6d8c763 --- /dev/null +++ b/src/features/nostr/store.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { SimplePool, type Event } from 'nostr-tools' + +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', +] + +function parseRelays(): string[] { + const env = import.meta.env.VITE_NOSTR_RELAYS + if (!env) return DEFAULT_RELAYS + const list = env + .split(',') + .map((r: string) => r.trim()) + .filter((r: string) => r.startsWith('wss://') || r.startsWith('ws://')) + return list.length ? list : DEFAULT_RELAYS +} + +export const useNostrStore = defineStore('nostr', () => { + const pool = new SimplePool() + const relays = ref(parseRelays()) + + async function publish(event: Event): Promise[]> { + return Promise.allSettled(pool.publish(relays.value, event)) + } + + return { relays, publish } +}) diff --git a/src/features/nostr/submitInquiry.ts b/src/features/nostr/submitInquiry.ts new file mode 100644 index 0000000..24ee305 --- /dev/null +++ b/src/features/nostr/submitInquiry.ts @@ -0,0 +1,69 @@ +import { nip17, nip19 } from 'nostr-tools' +import { generateSecretKey } from 'nostr-tools/pure' +import { useNostrStore } from './store' + +export type ContactMethod = 'email' | 'whatsapp' | 'signal' | 'telegram' | 'nostr' + +export interface InquiryPayload { + name?: string + contactMethod: ContactMethod + contactValue: string + message: string +} + +export interface SubmitResult { + ok: boolean + acceptedBy: number + attempted: number +} + +const METHOD_LABEL: Record = { + email: 'Email', + whatsapp: 'WhatsApp', + signal: 'Signal', + telegram: 'Telegram', + nostr: 'Nostr', +} + +function ownerHex(): string { + const npub = import.meta.env.VITE_OWNER_NPUB + if (!npub) { + throw new Error( + 'VITE_OWNER_NPUB is not set — the inquiry form has no destination.', + ) + } + const decoded = nip19.decode(npub.trim() as `npub1${string}`) + if (decoded.type !== 'npub') { + throw new Error( + `VITE_OWNER_NPUB must be an npub (got ${(decoded as { type: string }).type}).`, + ) + } + return decoded.data +} + +function formatMessage(p: InquiryPayload): string { + const lines: string[] = [] + lines.push('New inquiry — Earth Walker Design') + lines.push('') + if (p.name) lines.push(`From: ${p.name}`) + lines.push(`Preferred contact: ${METHOD_LABEL[p.contactMethod]} — ${p.contactValue}`) + lines.push('') + lines.push(p.message) + return lines.join('\n') +} + +export async function submitInquiry(payload: InquiryPayload): Promise { + const recipient = ownerHex() + const senderSk = generateSecretKey() + const wrapped = nip17.wrapEvent(senderSk, { publicKey: recipient }, formatMessage(payload)) + + const nostr = useNostrStore() + const results = await nostr.publish(wrapped) + + const accepted = results.filter((r) => r.status === 'fulfilled').length + return { + ok: accepted > 0, + acceptedBy: accepted, + attempted: results.length, + } +}