diff --git a/docker-compose.yml b/docker-compose.yml index 78b8c7f..a208ae5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER:-noreply@rmail.online} - SMTP_PASS=${SMTP_PASS} + - SITE_URL=https://rspace.online + - SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com - TWENTY_API_URL=http://twenty-ch-server:3000 - TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-} - TRANSAK_API_KEY=${TRANSAK_API_KEY:-} diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 04bcb65..98608fc 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -59,6 +59,21 @@ function getSmtpTransport(): Transporter | null { return _smtpTransport; } +// ── EncryptID internal email lookup ── + +const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + +async function lookupEncryptIDEmail(userId: string): Promise<{ email: string | null; username: string | null }> { + try { + const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/user-email/${encodeURIComponent(userId)}`); + if (!res.ok) return { email: null, username: null }; + return await res.json(); + } catch (e) { + console.warn('[rcart] EncryptID email lookup failed:', e); + return { email: null, username: null }; + } +} + const routes = new Hono(); // Provider registry URL (for fulfillment resolution) @@ -1641,11 +1656,33 @@ routes.patch("/api/payments/:id/status", async (c) => { const updated = _syncServer!.getDoc(docId); - // Fire-and-forget payment success email - if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) { + // Fire-and-forget payment emails to both payer and recipient + if (status === 'paid') { const host = c.req.header("host") || "rspace.online"; - sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space) - .catch((err) => console.error('[rcart] payment email failed:', err)); + const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); + + // Resolve payer email: from request body, or look up via EncryptID + (async () => { + let resolvedPayerEmail = payerEmail; + if (!resolvedPayerEmail && payerIdentity) { + const lookup = await lookupEncryptIDEmail(payerIdentity); + if (lookup.email) resolvedPayerEmail = lookup.email; + } + // Send "Payment Sent" email to payer + if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) { + sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space) + .catch((err) => console.error('[rcart] payer email failed:', err)); + } + // Send "Payment Received" email to recipient (creator) + const creatorDid = updated!.payment.creatorDid; + if (creatorDid) { + const creatorLookup = await lookupEncryptIDEmail(creatorDid); + if (creatorLookup.email && isValidEmail(creatorLookup.email)) { + sendPaymentReceivedEmail(creatorLookup.email, updated!.payment, host, space, resolvedPayerEmail) + .catch((err) => console.error('[rcart] recipient email failed:', err)); + } + } + })().catch((err) => console.error('[rcart] payment email resolution failed:', err)); } // Auto-record contribution on linked shopping cart @@ -2091,6 +2128,83 @@ async function sendPaymentSuccessEmail( }); } +async function sendPaymentReceivedEmail( + email: string, + p: PaymentRequestMeta, + host: string, + space: string, + payerEmail?: string, +) { + const transport = getSmtpTransport(); + if (!transport) return; + + const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`; + const explorer = CHAIN_EXPLORERS[p.chainId]; + const txLink = explorer && p.txHash + ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` + : (p.txHash || 'N/A'); + const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString(); + const dashboardUrl = `https://${host}/${space}/rcart`; + const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous'; + + const html = ` + + + +
+ + + + +
+
💰
+

You Received a Payment

+
+ + + + + + + + + + + +
Amount${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}
From${payerLabel}
Network${chainName}
Transaction${txLink}
Date${paidDate}
+ +
+

+ Sent by rSpace +

+
`; + + const text = [ + `You Received a Payment`, + ``, + `Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`, + `From: ${payerLabel}`, + `Network: ${chainName}`, + `Transaction: ${p.txHash || 'N/A'}`, + `Date: ${paidDate}`, + ``, + `View Dashboard: ${dashboardUrl}`, + ``, + `Sent by rSpace — ${dashboardUrl}`, + ].join('\n'); + + await transport.sendMail({ + from: 'rSpace ', + to: email, + subject: `Payment received \u2014 ${p.amount} ${p.token}`, + html, + text, + }); + console.log(`[rcart] Payment received email sent to ${email}`); +} + function paymentToResponse(p: PaymentRequestMeta) { return { id: p.id, diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index e64221c..ba12598 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -623,37 +623,14 @@ export class FolkSplatViewer extends HTMLElement { if (!container || !this._splatUrl) return; + const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf"); + try { - // Dynamic import from CDN (via importmap) - const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); - - const viewer = new GaussianSplats3D.Viewer({ - cameraUp: [0, 1, 0], - initialCameraPosition: [5, 3, 5], - initialCameraLookAt: [0, 0, 0], - rootElement: container, - sharedMemoryForWorkers: false, - }); - - this._viewer = viewer; - - viewer.addSplatScene(this._splatUrl, { - showLoadingUI: false, - progressiveLoad: true, - }) - .then(() => { - viewer.start(); - if (loading) loading.classList.add("hidden"); - }) - .catch((e: Error) => { - console.error("[rSplat] Scene load error:", e); - if (loading) { - const text = loading.querySelector(".splat-loading__text"); - if (text) text.textContent = `Error: ${e.message}`; - const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; - if (spinner) spinner.style.display = "none"; - } - }); + if (isGlb) { + await this.initGlbViewer(container, loading); + } else { + await this.initSplatViewer(container, loading); + } } catch (e) { console.error("[rSplat] Viewer init error:", e); if (loading) { @@ -664,6 +641,135 @@ export class FolkSplatViewer extends HTMLElement { } } } + + private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) { + const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); + + const viewer = new GaussianSplats3D.Viewer({ + cameraUp: [0, 1, 0], + initialCameraPosition: [5, 3, 5], + initialCameraLookAt: [0, 0, 0], + rootElement: container, + sharedMemoryForWorkers: false, + }); + + this._viewer = viewer; + + viewer.addSplatScene(this._splatUrl!, { + showLoadingUI: false, + progressiveLoad: true, + }) + .then(() => { + viewer.start(); + if (loading) loading.classList.add("hidden"); + }) + .catch((e: Error) => { + console.error("[rSplat] Scene load error:", e); + if (loading) { + const text = loading.querySelector(".splat-loading__text"); + if (text) text.textContent = `Error: ${e.message}`; + const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; + if (spinner) spinner.style.display = "none"; + } + }); + } + + private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) { + const THREE = await import("three"); + const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js"); + const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js"); + + const w = container.clientWidth || 800; + const h = container.clientHeight || 600; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0f0f14); + + const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000); + camera.position.set(3, 2, 3); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(w, h); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.2; + container.appendChild(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.target.set(0, 0, 0); + + // Lighting + const ambient = new THREE.AmbientLight(0xffffff, 0.6); + scene.add(ambient); + const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); + dirLight.position.set(5, 10, 7); + scene.add(dirLight); + const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4); + fillLight.position.set(-5, 3, -5); + scene.add(fillLight); + + // Load GLB + const loader = new GLTFLoader(); + loader.load( + this._splatUrl!, + (gltf: any) => { + const model = gltf.scene; + // Auto-center and scale + const box = new THREE.Box3().setFromObject(model); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + const scale = 2 / maxDim; + model.scale.setScalar(scale); + model.position.sub(center.multiplyScalar(scale)); + scene.add(model); + + controls.target.set(0, 0, 0); + controls.update(); + + if (loading) loading.classList.add("hidden"); + }, + undefined, + (err: any) => { + console.error("[rSplat] GLB load error:", err); + if (loading) { + const text = loading.querySelector(".splat-loading__text"); + if (text) text.textContent = `Error loading GLB: ${err.message || err}`; + const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; + if (spinner) spinner.style.display = "none"; + } + } + ); + + // Animation loop + let animId: number; + const animate = () => { + animId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + // Handle resize + const onResize = () => { + const rw = container.clientWidth || 800; + const rh = container.clientHeight || 600; + camera.aspect = rw / rh; + camera.updateProjectionMatrix(); + renderer.setSize(rw, rh); + }; + window.addEventListener("resize", onResize); + + // Store cleanup reference + (this as any)._glbCleanup = () => { + cancelAnimationFrame(animId); + window.removeEventListener("resize", onResize); + controls.dispose(); + renderer.dispose(); + }; + } } // ── Helpers ── diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 46eca3e..4ba0fd1 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -726,6 +726,7 @@ export const splatModule: RSpaceModule = { name: "rSplat", icon: "🔮", description: "3D Gaussian splat viewer", + publicWrite: true, scoping: { defaultScope: 'global', userConfigurable: true }, docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }], routes, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 57f4930..a3d7c53 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -1112,6 +1112,7 @@ app.get('/api/account/status', async (c) => { return c.json({ email: hasEmail, + emailAddress: profile?.profileEmail || null, multiDevice: hasMultiDevice, socialRecovery: hasRecovery, credentialCount: creds.length, @@ -3385,6 +3386,15 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => { return c.json({ success: true }); }); +// ── Internal: email lookup by userId (no auth, internal network only) ── +app.get('/api/internal/user-email/:userId', async (c) => { + const userId = c.req.param('userId'); + if (!userId) return c.json({ error: 'userId required' }, 400); + const profile = await getUserProfile(userId); + if (!profile) return c.json({ error: 'User not found' }, 404); + return c.json({ email: profile.profileEmail || null, username: profile.username || null }); +}); + // ============================================================================ // USER LOOKUP // ============================================================================