From a255f1e5b0afc16f1c552af944713dafd1ec2503 Mon Sep 17 00:00:00 2001
From: austinkelsay <austinkelsay@yahoo.com>
Date: Mon, 22 Apr 2024 19:09:46 -0500
Subject: [PATCH] Working on zaps

---
 package-lock.json                             |   6 +
 package.json                                  |   1 +
 .../carousels/templates/CourseTemplate.js     | 120 +++++++++---------
 .../carousels/templates/ResourceTemplate.js   |   4 +-
 .../carousels/templates/WorkshopTemplate.js   |   4 +-
 src/components/zaps/ZapDisplay.js             |  19 +++
 src/components/zaps/ZapForm.js                |  49 +++++++
 src/hooks/useNostr.js                         |  83 ++++++++----
 src/utils/lnurl.js                            |  12 ++
 src/utils/nostr.js                            |   4 +
 10 files changed, 211 insertions(+), 91 deletions(-)
 create mode 100644 src/components/zaps/ZapDisplay.js
 create mode 100644 src/components/zaps/ZapForm.js
 create mode 100644 src/utils/lnurl.js

diff --git a/package-lock.json b/package-lock.json
index 4c9c346..a6b165c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
         "@prisma/client": "^5.9.1",
         "@reduxjs/toolkit": "^2.1.0",
         "axios": "^1.6.7",
+        "bech32": "^2.0.0",
         "classnames": "^2.5.1",
         "light-bolt11-decoder": "^3.1.1",
         "next": "14.0.4",
@@ -1378,6 +1379,11 @@
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "dev": true
     },
+    "node_modules/bech32": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
+      "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg=="
+    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
diff --git a/package.json b/package.json
index b71b5df..049b7b5 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
     "@prisma/client": "^5.9.1",
     "@reduxjs/toolkit": "^2.1.0",
     "axios": "^1.6.7",
+    "bech32": "^2.0.0",
     "classnames": "^2.5.1",
     "light-bolt11-decoder": "^3.1.1",
     "next": "14.0.4",
diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js
index 1b6fdc6..22a7110 100644
--- a/src/components/content/carousels/templates/CourseTemplate.js
+++ b/src/components/content/carousels/templates/CourseTemplate.js
@@ -5,73 +5,71 @@ import { formatTimestampToHowLongAgo } from "@/utils/time";
 import { useImageProxy } from "@/hooks/useImageProxy";
 import { useNostr } from "@/hooks/useNostr";
 import { getSatAmountFromInvoice } from "@/utils/lightning";
+import ZapDisplay from "@/components/zaps/ZapDisplay";
 
 const CourseTemplate = (course) => {
-  const [zaps, setZaps] = useState([]);
-  const [zapAmount, setZapAmount] = useState(null);
-  const router = useRouter();
-  const { returnImageProxy } = useImageProxy();
-  const { fetchZapsForEvent } = useNostr();
+    const [zaps, setZaps] = useState([]);
+    const [zapAmount, setZapAmount] = useState(null);
+    const router = useRouter();
+    const { returnImageProxy } = useImageProxy();
+    const { fetchZapsForEvent } = useNostr();
 
-  useEffect(() => {
-    const fetchZaps = async () => {
-      try {
-        const zaps = await fetchZapsForEvent(course.id);
-        setZaps(zaps);
-      } catch (error) {
-        console.error("Error fetching zaps:", error);
-      }
-    };
-    fetchZaps();
-  }, [fetchZapsForEvent, course]);
+    useEffect(() => {
+        const fetchZaps = async () => {
+            try {
+                const zaps = await fetchZapsForEvent(course);
+                // console.log('zaps:', zaps);
+                if (zaps.length > 0) {
+                    let total = 0;
+                    zaps.map((zap) => {
+                        const bolt11Tag = zap.tags.find((tag) => tag[0] === "bolt11");
+                        const invoice = bolt11Tag ? bolt11Tag[1] : null;
+                        if (invoice) {
+                            const amount = getSatAmountFromInvoice(invoice);
+                            total += amount;
+                        }
+                    });
+                    setZapAmount(total);
+                }
+            } catch (error) {
+                console.error("Error fetching zaps:", error);
+            }
+        };
+        fetchZaps();
+    }, [fetchZapsForEvent, course]);
 
-  useEffect(() => {
-    if (zaps.length > 0) {
-      zaps.map((zap) => {
-        const bolt11Tag = zap.tags.find((tag) => tag[0] === "bolt11");
-        const invoice = bolt11Tag ? bolt11Tag[1] : null;
-        if (invoice) {
-          const amount = getSatAmountFromInvoice(invoice);
-          setZapAmount(zapAmount + amount);
-        }
-      });
-    }
-  }, [zaps]);
-
-  return (
-    <div
-      className="flex flex-col items-center mx-auto px-4 mt-8 rounded-md"
-    >
-      <div 
-        onClick={() => router.push(`/details/${course.id}`)}
-        className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300"
-        style={{ paddingBottom: "56.25%"}}
+    return (
+        <div
+            className="flex flex-col items-center mx-auto px-4 mt-8 rounded-md"
         >
-        <Image
-          alt="course thumbnail"
-          src={returnImageProxy(course.image)}
-          quality={100}
-          layout="fill"
-          objectFit="cover"
-          className="rounded-md"
-        />
-      </div>
-      <div className="flex flex-col justify-start w-full mt-4">
-        <h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2">
-          {course.title}
-        </h4>
-        <p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p>
-        <div className="flex flex-row justify-between items-center mt-2">
-          <p className="text-xs text-gray-400">
-            {formatTimestampToHowLongAgo(course.published_at)}
-          </p>
-          <p className="text-xs cursor-pointer">
-            <i className="pi pi-bolt text-yellow-300"></i> {zapAmount}
-          </p>
+            <div
+                onClick={() => router.push(`/details/${course.id}`)}
+                className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer"
+                style={{ paddingBottom: "56.25%" }}
+            >
+                <Image
+                    alt="course thumbnail"
+                    src={returnImageProxy(course.image)}
+                    quality={100}
+                    layout="fill"
+                    objectFit="cover"
+                    className="rounded-md"
+                />
+            </div>
+            <div className="flex flex-col justify-start w-full mt-4">
+                <h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2">
+                    {course.title}
+                </h4>
+                <p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p>
+                <div className="flex flex-row justify-between items-center mt-2">
+                    <p className="text-xs text-gray-400">
+                        {formatTimestampToHowLongAgo(course.published_at)}
+                    </p>
+                    <ZapDisplay zapAmount={zapAmount} event={course} />
+                </div>
+            </div>
         </div>
-      </div>
-    </div>
-  );
+    );
 };
 
 export default CourseTemplate;
\ No newline at end of file
diff --git a/src/components/content/carousels/templates/ResourceTemplate.js b/src/components/content/carousels/templates/ResourceTemplate.js
index acac2e4..e21a3fc 100644
--- a/src/components/content/carousels/templates/ResourceTemplate.js
+++ b/src/components/content/carousels/templates/ResourceTemplate.js
@@ -16,7 +16,7 @@ const ResourceTemplate = (resource) => {
   useEffect(() => {
     const fetchZaps = async () => {
       try {
-        const zaps = await fetchZapsForEvent(resource.id);
+        const zaps = await fetchZapsForEvent(resource);
         setZaps(zaps);
       } catch (error) {
         console.error("Error fetching zaps:", error);
@@ -45,7 +45,7 @@ const ResourceTemplate = (resource) => {
         {/* Wrap the image in a div with a relative class with a padding-bottom of 56.25% representing the aspect ratio of 16:9 */}
       <div 
         onClick={() => router.push(`/details/${resource.id}`)}
-        className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300"
+        className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer"
         style={{ paddingBottom: "56.25%"}}
       >
         <Image
diff --git a/src/components/content/carousels/templates/WorkshopTemplate.js b/src/components/content/carousels/templates/WorkshopTemplate.js
index 7946b19..01d7d10 100644
--- a/src/components/content/carousels/templates/WorkshopTemplate.js
+++ b/src/components/content/carousels/templates/WorkshopTemplate.js
@@ -16,7 +16,7 @@ const WorkshopTemplate = (workshop) => {
   useEffect(() => {
     const fetchZaps = async () => {
       try {
-        const zaps = await fetchZapsForEvent(workshop.id);
+        const zaps = await fetchZapsForEvent(workshop);
         setZaps(zaps);
       } catch (error) {
         console.error("Error fetching zaps:", error);
@@ -44,7 +44,7 @@ const WorkshopTemplate = (workshop) => {
     >
       <div 
         onClick={() => router.push(`/details/${workshop.id}`)}
-        className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300"
+        className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer"
         style={{ paddingBottom: "56.25%"}}
         >
         <Image
diff --git a/src/components/zaps/ZapDisplay.js b/src/components/zaps/ZapDisplay.js
new file mode 100644
index 0000000..3ea2829
--- /dev/null
+++ b/src/components/zaps/ZapDisplay.js
@@ -0,0 +1,19 @@
+import React, { useRef } from 'react';
+import { OverlayPanel } from 'primereact/overlaypanel';
+import ZapForm from './ZapForm';
+
+const ZapDisplay = ({ zapAmount, event }) => {
+    const op = useRef(null);
+    return (
+        <>
+            <p className="text-xs cursor-pointer" onClick={(e) => op.current.toggle(e)}>
+                <i className="pi pi-bolt text-yellow-300"></i> {zapAmount}
+            </p>
+            <OverlayPanel ref={op}>
+                <ZapForm event={event} />
+            </OverlayPanel>
+        </>
+    )
+}
+
+export default ZapDisplay;
\ No newline at end of file
diff --git a/src/components/zaps/ZapForm.js b/src/components/zaps/ZapForm.js
new file mode 100644
index 0000000..4c915f3
--- /dev/null
+++ b/src/components/zaps/ZapForm.js
@@ -0,0 +1,49 @@
+import React, { useState } from "react";
+import { Button } from 'primereact/button';
+import { InputText } from 'primereact/inputtext';
+import { InputTextarea } from 'primereact/inputtextarea';
+import { useNostr } from "@/hooks/useNostr";
+
+const ZapForm = ({event}) => {
+    const [zapAmount, setZapAmount] = useState(0);
+    const [comment, setComment] = useState("");
+
+    const { zapEvent } = useNostr();
+
+    const handleZapButton = (amount) => {
+        setZapAmount(amount);
+    };
+
+    const handleCustomAmountChange = (event) => {
+        setZapAmount(event.target.value);
+    };
+
+    const handleCommentChange = (event) => {
+        setComment(event.target.value);
+    };
+
+    const handleSubmit = async () => {
+        const millisatAmount = zapAmount * 1000;
+        const response = await zapEvent(event, millisatAmount, comment);
+
+        console.log('zap response:', response);
+    };
+
+    return (
+        <div className="flex flex-col">
+            <div className="flex flex-row justify-start">
+                {[1, 10, 21, 100, 500, 1000].map(amount => (
+                    <Button key={amount} label={amount.toString()} icon="pi pi-bolt" severity="success"
+                            rounded className="mr-2" onClick={() => handleZapButton(amount)} />
+                ))}
+            </div>
+            <div className="flex flex-row w-[100%] justify-between my-4">
+                <InputText placeholder="Custom Amount" value={zapAmount} onChange={handleCustomAmountChange} />
+            </div>
+            <InputTextarea rows={5} placeholder="Message" value={comment} onChange={handleCommentChange} />
+            <Button label="Zap" icon="pi pi-bolt" severity="success" className="mt-4" onClick={handleSubmit} />
+        </div>
+    );
+};
+
+export default ZapForm;
diff --git a/src/hooks/useNostr.js b/src/hooks/useNostr.js
index 3fcf945..38e8f8e 100644
--- a/src/hooks/useNostr.js
+++ b/src/hooks/useNostr.js
@@ -1,7 +1,8 @@
 import { useState, useEffect, useCallback, useContext } from 'react';
 import axios from 'axios';
-import { nip57 } from 'nostr-tools';
+import { nip57, nip19 } from 'nostr-tools';
 import { NostrContext } from '@/context/NostrContext';
+import { lnurlEncode } from '@/utils/lnurl';
 
 const defaultRelays = [
     "wss://nos.lol/",
@@ -11,19 +12,19 @@ const defaultRelays = [
     "wss://nostr.mutinywallet.com/",
     "wss://relay.mutinywallet.com/",
     "wss://relay.primal.net/"
-  ];
+];
 
 export function useNostr() {
     const pool = useContext(NostrContext);
 
     const subscribe = useCallback(
         (filters, opts) => {
-          if (!pool) return;
-    
-          return pool.subscribeMany(defaultRelays, filters, opts);
+            if (!pool) return;
+
+            return pool.subscribeMany(defaultRelays, filters, opts);
         },
         [pool]
-      );
+    );
 
     const publish = useCallback(
         async (event) => {
@@ -55,11 +56,16 @@ export function useNostr() {
     );
 
     const fetchZapsForEvent = useCallback(
-        async (id) => {
+        async (event) => {
             try {
-                const filter = { kinds: [9735], '#e': [id] };
-                const zaps = await pool.querySync(defaultRelays, filter);
-                console.log('zaps:', zaps);
+                let zaps = [];
+                const paramaterizedFilter = { kinds: [9735], '#a': [`${event.kind}:${event.id}:${event.d}`] };
+                const paramaterizedZaps = await pool.querySync(defaultRelays, paramaterizedFilter);
+                console.log('paramaterizedZaps:', paramaterizedZaps);
+                const filter = { kinds: [9735], '#e': [event.id] };
+                const zapsForEvent = await pool.querySync(defaultRelays, filter);
+                console.log('zapsForEvent:', zapsForEvent);
+                zaps = zaps.concat(paramaterizedZaps, zapsForEvent);
                 return zaps;
             } catch (error) {
                 console.error('Failed to fetch zaps for event:', error);
@@ -84,7 +90,7 @@ export function useNostr() {
     );
 
     const zapEvent = useCallback(
-        async (event) => {
+        async (event, amount, comment) => {
             const kind0 = await fetchKind0(event.pubkey);
 
             if (kind0.length === 0) {
@@ -104,16 +110,41 @@ export function useNostr() {
                         const zapReq = nip57.makeZapRequest({
                             profile: event.pubkey,
                             event: event.id,
-                            amount: 1000,
+                            amount: amount,
                             relays: defaultRelays,
-                            comment: 'Plebdevs Zap',
+                            comment: comment ? comment : 'Plebdevs Zap',
                         });
 
-                        console.log('zapReq:', zapReq);
+                        const user = window.localStorage.getItem('user');
+
+                        const pubkey = JSON.parse(user).pubkey;
+
+                        const lnurl = lnurlEncode(lud16Url)
+
+                        console.log('lnurl:', lnurl);
+
+                        console.log('pubkey:', pubkey);
+
+                        // const zapRequest = {
+                        //     kind: 9734,
+                        //     content: "",
+                        //     tags: [
+                        //         ["relays", defaultRelays[4], defaultRelays[5]],
+                        //         ["amount", amount.toString()],
+                        //         //   ["lnurl", lnurl],
+                        //         ["e", event.id],
+                        //         ["p", event.pubkey],
+                        //         // ["a", `${event.kind}:${event.id}:${event.d}`],
+                        //     ],
+                        //     created_at: Math.floor(Date.now() / 1000)
+                        // }
+
+                        console.log('zapRequest:', zapReq);
 
                         const signedEvent = await window?.nostr?.signEvent(zapReq);
+                        console.log('signedEvent:', signedEvent);
                         const callbackUrl = response.data.callback;
-                        const zapRequestAPICall = `${callbackUrl}?amount=${1000}&nostr=${encodeURI(
+                        const zapRequestAPICall = `${callbackUrl}?amount=${amount}&nostr=${encodeURI(
                             JSON.stringify(signedEvent)
                         )}`;
 
@@ -149,10 +180,10 @@ export function useNostr() {
             const hasResource = eventData.some(([tag, value]) => tag === "t" && value === "resource");
             return hasPlebDevs && hasResource;
         };
-    
+
         return new Promise((resolve, reject) => {
             let resources = [];
-    
+
             const subscription = subscribe(
                 filter,
                 {
@@ -172,14 +203,14 @@ export function useNostr() {
                 },
                 2000 // Adjust the timeout value as needed
             );
-    
+
             setTimeout(() => {
                 subscription?.close();
                 resolve(resources);
             }, 2000); // Adjust the timeout value as needed
         });
     }, [subscribe]);
-    
+
     const fetchWorkshops = useCallback(async () => {
         const filter = [{ kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"] }];
         const hasRequiredTags = (eventData) => {
@@ -187,10 +218,10 @@ export function useNostr() {
             const hasWorkshop = eventData.some(([tag, value]) => tag === "t" && value === "workshop");
             return hasPlebDevs && hasWorkshop;
         };
-    
+
         return new Promise((resolve, reject) => {
             let workshops = [];
-    
+
             const subscription = subscribe(
                 filter,
                 {
@@ -210,14 +241,14 @@ export function useNostr() {
                 },
                 2000 // Adjust the timeout value as needed
             );
-    
+
             setTimeout(() => {
                 subscription?.close();
                 resolve(workshops);
             }, 2000); // Adjust the timeout value as needed
         });
     }, [subscribe]);
-    
+
     const fetchCourses = useCallback(async () => {
         const filter = [{ kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"] }];
         const hasRequiredTags = (eventData) => {
@@ -225,10 +256,10 @@ export function useNostr() {
             const hasCourse = eventData.some(([tag, value]) => tag === "t" && value === "course");
             return hasPlebDevs && hasCourse;
         };
-    
+
         return new Promise((resolve, reject) => {
             let courses = [];
-    
+
             const subscription = subscribe(
                 filter,
                 {
@@ -248,7 +279,7 @@ export function useNostr() {
                 },
                 2000 // Adjust the timeout value as needed
             );
-    
+
             setTimeout(() => {
                 subscription?.close();
                 resolve(courses);
diff --git a/src/utils/lnurl.js b/src/utils/lnurl.js
new file mode 100644
index 0000000..39f1b88
--- /dev/null
+++ b/src/utils/lnurl.js
@@ -0,0 +1,12 @@
+import {bech32} from 'bech32';
+
+export const lnurlEncode = (data) => {
+    console.log('data:', data);
+    const words = bech32.toWords(Buffer.from(data, 'utf8'));
+    return bech32.encode("lnurl", words, 2000).toUpperCase()
+};
+
+export const lnurlDecode = (encoded) => {
+    const { words } = bech32.decode(encoded, 90);
+    return Buffer.from(bech32.fromWords(words)).toString('utf8');
+};
\ No newline at end of file
diff --git a/src/utils/nostr.js b/src/utils/nostr.js
index c885425..ddb42d3 100644
--- a/src/utils/nostr.js
+++ b/src/utils/nostr.js
@@ -35,6 +35,7 @@ export const parseEvent = (event) => {
         id: event.id,
         pubkey: event.pubkey || '',
         content: event.content || '',
+        kind: event.kind || '',
         title: '',
         summary: '',
         image: '',
@@ -59,6 +60,9 @@ export const parseEvent = (event) => {
             case 'author':
                 eventData.author = tag[1];
                 break;
+            case 'd':
+                eventData.d = tag[1];
+                break;
             default:
                 break;
         }