This commit is contained in:
zmeyer44 2023-10-23 21:30:31 -04:00
parent 9b0996b18f
commit f952f4d456
8 changed files with 512 additions and 23 deletions

View File

@ -0,0 +1,290 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import useProfile from "@/lib/hooks/useProfile";
import { HiOutlineLightningBolt } from "react-icons/hi";
import Spinner from "@/components/spinner";
import {
getTagAllValues,
getTagValues,
getTagsValues,
} from "@/lib/nostr/utils";
import ProfileInfo from "./ProfileInfo";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { useNDK } from "@/app/_providers/ndk";
import { toast } from "sonner";
import {
sendZap,
checkPayment,
updateListUsersFromZaps,
} from "@/lib/actions/zap";
import { useModal } from "@/app/_providers/modal/provider";
import { type NDKEvent } from "@nostr-dev-kit/ndk";
import { btcToSats, formatNumber } from "@/lib/utils";
import { formatDate } from "@/lib/utils/dates";
import SmallCalendarIcon from "@/components/EventIcons/DateIcon";
import LocationIcon from "@/components/EventIcons/LocationIcon";
const EditListModal = dynamic(() => import("@/components/Modals/EditList"), {
ssr: false,
});
const CreateListEvent = dynamic(
() => import("@/components/Modals/ShortTextNoteOnList"),
{
ssr: false,
},
);
const ConfirmModal = dynamic(() => import("@/components/Modals/Confirm"), {
ssr: false,
});
export default function Header({ event }: { event: NDKEvent }) {
const { currentUser } = useCurrentUser();
const modal = useModal();
const { ndk } = useNDK();
const [checkingPayment, setCheckingPayment] = useState(false);
const [hasValidPayment, setHasValidPayment] = useState(false);
const [syncingUsers, setSyncingUsers] = useState(false);
const { pubkey, tags } = event;
const { profile } = useProfile(pubkey);
console.log("EVENT", tags);
const noteIds = getTagsValues("e", tags).filter(Boolean);
const title = getTagValues("name", tags) ?? "Untitled";
console.log("tite", tags);
const image =
getTagValues("image", tags) ??
getTagValues("picture", tags) ??
getTagValues("banner", tags) ??
profile?.banner;
const description = event.content;
const startDate = getTagValues("start", tags)
? new Date(parseInt(getTagValues("start", tags) as string) * 1000)
: null;
const endDate = getTagValues("end", tags)
? new Date(parseInt(getTagValues("end", tags) as string) * 1000)
: null;
const getLocation = () => {
let temp = getTagAllValues("location", tags);
if (temp[0]) {
return temp;
}
return getTagAllValues("address", tags);
};
const location = getLocation();
const rawEvent = event.rawEvent();
const subscriptionsEnabled = !!getTagValues("subscriptions", rawEvent.tags);
const priceInBTC = parseFloat(getTagValues("price", rawEvent.tags) ?? "0");
const isMember =
currentUser &&
getTagsValues("p", rawEvent.tags).includes(currentUser.pubkey);
useEffect(() => {
if (!currentUser || !subscriptionsEnabled) return;
if (!isMember && !checkingPayment && !hasValidPayment) {
void handleCheckPayment();
}
}, [isMember, currentUser]);
async function handleCheckPayment() {
if (!event || !currentUser || !ndk) return;
setCheckingPayment(true);
console.log("Checking payment");
try {
const result = await checkPayment(
ndk,
event.tagId(),
currentUser.pubkey,
rawEvent,
);
console.log("Payment result", result);
if (result) {
setHasValidPayment(true);
}
} catch (err) {
console.log("error sending zap", err);
} finally {
setCheckingPayment(false);
}
}
async function handleSyncUsers() {
if (!event || !ndk) return;
setSyncingUsers(true);
try {
console.log("handleSyncUsers");
await updateListUsersFromZaps(ndk, event.tagId(), rawEvent);
toast.success("Users Synced!");
} catch (err) {
console.log("error syncing users", err);
} finally {
setSyncingUsers(false);
}
}
async function handleSendZap() {
try {
const result = await sendZap(
ndk!,
btcToSats(priceInBTC),
rawEvent,
`Access payment: ${title}`,
);
toast.success("Payment Sent!");
void handleCheckPayment();
} catch (err) {
console.log("error sending zap", err);
} finally {
}
}
if (!event) {
return (
<div className="center pt-20 text-primary">
<Spinner />
</div>
);
}
return (
<div className="relative overflow-hidden rounded-[1rem] border bg-muted p-[0.5rem] @container">
<div className="overflow-hidden rounded-[0.5rem] p-0">
<div className="relative w-full overflow-hidden bg-gradient-to-b from-primary pb-[50%] @5xl:rounded-[20px] md:pb-[40%]">
{!!image && (
<Image
className="absolute inset-0 h-full w-full object-cover align-middle"
src={image}
width={400}
height={100}
alt="banner"
unoptimized
/>
)}
</div>
</div>
<div className="space-y-1 p-3 @sm:px-3.5 @sm:pb-2 @sm:pt-5">
<div className="flex items-start justify-between gap-x-1.5 @lg:gap-x-2.5">
<div className="space-y-1 @sm:space-y-2">
<h2 className="font-condensed text-2xl font-semibold sm:text-3xl lg:text-4xl">
{title}
</h2>
<div className="flex items-center">
<ProfileInfo pubkey={pubkey} />
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
{!!currentUser && currentUser.pubkey === pubkey && (
<>
<Button onClick={() => modal?.show(<CreateListEvent />)}>
Add Event
</Button>
<Button
variant={"outline"}
loading={syncingUsers}
onClick={() => void handleSyncUsers()}
>
Sync users
</Button>
<Button
variant="ghost"
onClick={() =>
modal?.show(<EditListModal listEvent={rawEvent} />)
}
>
Edit
</Button>
</>
)}
{subscriptionsEnabled &&
!isMember &&
(hasValidPayment ? (
<Button variant={"outline"}>Pending Sync</Button>
) : (
<Button
onClick={() =>
modal?.show(
<ConfirmModal
title={`Subscribe to ${title}`}
onConfirm={handleSendZap}
ctaBody={
<>
<span>Zap to Subscribe</span>
<HiOutlineLightningBolt className="h-4 w-4" />
</>
}
>
<p className="text-muted-forground">
{`Pay ${priceInBTC} BTC (${formatNumber(
btcToSats(priceInBTC),
)} sats) for year long access until ${formatDate(
new Date(
new Date().setFullYear(
new Date().getFullYear() + 1,
),
),
"MMM Do, YYYY",
)}`}
</p>
</ConfirmModal>,
)
}
>
RSVP
</Button>
))}
</div>
</div>
<div className="flex pt-1 @md:pt-2">
<div className="flex-1">
{!!description && (
<p className="line-clamp-3 text-sm text-muted-foreground md:text-sm">
{description}
</p>
)}
</div>
<div className="flex flex-1 justify-end">
<div className="flex flex-col gap-3 pr-3">
{!!startDate && (
<div className="flex flex-1 items-center gap-3">
<SmallCalendarIcon date={startDate} />
<div className="">
<p className="text-bold text-base">
{formatDate(startDate, "dddd, MMMM Do")}
</p>
{!!endDate ? (
<p className="text-sm text-muted-foreground">{`${formatDate(
startDate,
"h:mm a",
)} to ${formatDate(endDate, "h:mm a")}`}</p>
) : (
<p className="text-xs text-muted-foreground">{`${formatDate(
startDate,
"h:mm a",
)}`}</p>
)}
</div>
</div>
)}
{!!location && (
<div className="flex flex-1 items-center gap-3">
<LocationIcon />
<div className="">
{location.length > 2 ? (
<>
<p className="text-bold text-base">{location[1]}</p>
<p className="text-xs text-muted-foreground">
{location[2]}
</p>
</>
) : (
<p className="text-sm">{location[0]}</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import Link from "next/link";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import useProfile from "@/lib/hooks/useProfile";
import { nip19 } from "nostr-tools";
import { getTwoLetters, getNameToShow } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { HiMiniChevronRight, HiCheckBadge } from "react-icons/hi2";
type ProfileInfoProps = {
pubkey: string;
};
export default function ProfileInfo({ pubkey }: ProfileInfoProps) {
const { profile } = useProfile(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<Link
href={`/${npub}`}
className="center group gap-x-2 rounded-sm rounded-r-full border bg-background/50 pl-0.5 pr-1 text-muted-foreground hover:shadow"
>
<Avatar className="center h-[16px] w-[16px] overflow-hidden rounded-[.25rem] bg-muted @sm:h-[18px] @sm:w-[18px]">
<AvatarImage src={profile?.image} alt={profile?.displayName} />
<AvatarFallback className="text-[8px]">
{getTwoLetters({ npub, profile })}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1">
<span className="text-[14px] ">{getNameToShow({ npub, profile })}</span>
{!!profile?.nip05 && <HiCheckBadge className="h-3 w-3 text-primary" />}
</div>
<HiMiniChevronRight className="h-4 w-4" />
</Link>
);
}
export function LoadingProfileInfo() {
return (
<div className="center group gap-x-1">
<Avatar className="center h-[16px] w-[16px] overflow-hidden rounded-[.25rem] bg-muted @sm:h-[18px] @sm:w-[18px]"></Avatar>
<div className="space-y-1">
<Skeleton className="h-2 w-[70px] bg-muted" />
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { nip19 } from "nostr-tools";
import useEvents from "@/lib/hooks/useEvents";
import Spinner from "@/components/spinner";
import { getTagValues, getTagsValues } from "@/lib/nostr/utils";
import Feed from "@/containers/Feed";
import Header from "./_components/Header";
export default function EventPage({
params: { naddr },
}: {
params: {
naddr: string;
};
}) {
const { type, data } = nip19.decode(naddr);
if (type !== "naddr") {
throw new Error("Invalid list");
}
const { identifier, kind, pubkey } = data;
const { events } = useEvents({
filter: {
authors: [pubkey],
kinds: [kind],
["#d"]: [identifier],
limit: 1,
},
});
const event = events[0];
if (!event) {
return (
<div className="center pt-20 text-primary">
<Spinner />
</div>
);
}
const noteIds = getTagsValues("e", event.tags).filter(Boolean);
return (
<div className="relative mx-auto max-w-5xl space-y-4 p-2 sm:p-4">
<Header event={event} />
<div className="relative overflow-hidden rounded-[1rem] border bg-muted p-[0.5rem] @container">
<div className="space-y-3 overflow-hidden rounded-[0.5rem] p-0">
<Feed
filter={{
ids: noteIds,
}}
empty={() => (
<div className="text-center text-muted-foreground">
<p>No notes yet</p>
</div>
)}
/>
</div>
</div>
</div>
);
}

View File

@ -5,7 +5,7 @@ type SmallCalendarIconProps = {
};
export default function SmallCalendarIcon({ date }: SmallCalendarIconProps) {
return (
<div className="center h-10 w-10 overflow-hidden rounded-sm border text-muted-foreground">
<div className="center h-10 w-10 overflow-hidden rounded-sm border bg-background text-muted-foreground">
<div className="w-full text-center">
<div className="bg-muted p-[2px] text-[10px] font-semibold uppercase">
{formatDate(date, "MMM")}

View File

@ -119,10 +119,12 @@ function CommandSearch({ location, onSelect }: CommandSearchProps) {
>
{location ? (
<div className="flex max-w-full items-baseline gap-x-2">
{location.name}
<span className="truncate text-xs text-muted-foreground">
{location.address}
</span>
<p className="line-clamp-1">
{location.name}
<span className="ml-1 text-xs text-muted-foreground">
{location.address}
</span>
</p>
</div>
) : (
"Add a location..."

View File

@ -1,29 +1,40 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Template from "./Template";
import { Textarea } from "@/components/ui/textarea";
import useAutosizeTextArea from "@/lib/hooks/useAutoSizeTextArea";
import { Button } from "@/components/ui/button";
import { HiX } from "react-icons/hi";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
addMinutesToDate,
convertToTimezoneDate,
convertToTimezone,
} from "@/lib/utils/dates";
import { randomId } from "@/lib/nostr";
import { unixTimeNowInSeconds } from "@/lib/nostr/dates";
import { addMinutesToDate, toUnix, convertToTimezone } from "@/lib/utils/dates";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { DatePicker } from "@/components/ui/date-picker";
import { TimePicker } from "@/components/ui/time-picker";
import { TimezoneSelector } from "../ui/timezone";
import SmallCalendarIcon from "../EventIcons/DateIcon";
import LocationIcon from "../EventIcons/LocationIcon";
import LocationSearchInput from "../LocationSearch";
import { TimezoneSelector } from "@/components/ui/timezone";
import { Label } from "@/components/ui/label";
import SmallCalendarIcon from "@/components/EventIcons/DateIcon";
import LocationIcon from "@/components/EventIcons/LocationIcon";
import LocationSearchInput from "@/components/LocationSearch";
import useAutosizeTextArea from "@/lib/hooks/useAutoSizeTextArea";
import { useModal } from "@/app/_providers/modal/provider";
import { useRouter } from "next/navigation";
import { createEvent } from "@/lib/actions/create";
import { useNDK } from "@/app/_providers/ndk";
import { type NostrEvent } from "@nostr-dev-kit/ndk";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
export default function CreateCalendarEventModal() {
const modal = useModal();
const now = new Date(new Date().setHours(12, 0, 0, 0));
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState<Date>(now);
const startTime = `${
startDate?.getHours().toLocaleString().length === 1
@ -34,7 +45,7 @@ export default function CreateCalendarEventModal() {
? "0" + startDate?.getMinutes().toLocaleString()
: startDate?.getMinutes()
}`;
const [endDate, setEndDate] = useState<Date | undefined>(
const [endDate, setEndDate] = useState<Date>(
new Date(new Date().setHours(13)),
);
const endTime = `${
@ -54,6 +65,65 @@ export default function CreateCalendarEventModal() {
name: string;
coordinates: { lat: number; lng: number };
}>();
const { ndk } = useNDK();
const { currentUser } = useCurrentUser();
const router = useRouter();
async function handleSubmit() {
console.log("CALLED", ndk, currentUser);
if (!ndk || !currentUser) return;
setIsLoading(true);
if (!title) {
setError("Please add a title");
return;
}
try {
const random = randomId();
const tags: string[][] = [
["d", random],
["name", title],
["description", description],
["start", toUnix(convertToTimezone(startDate, timezone)).toString()],
["end", toUnix(convertToTimezone(endDate, timezone)).toString()],
["start_tzid", timezone],
];
if (location) {
tags.push([
"location",
`${location.name}, ${location.address}`,
location.name,
location.address,
]);
tags.push([
"address",
`${location.name}, ${location.address}`,
location.name,
location.address,
]);
}
console.log("Adding ", tags);
const preEvent = {
content: description,
pubkey: currentUser.pubkey,
created_at: unixTimeNowInSeconds(),
tags: tags,
kind: 31923,
};
const event = await createEvent(ndk, preEvent);
if (event) {
toast.success("Event Created!");
modal?.hide();
router.push(`/event/${event.encode()}`);
} else {
toast.error("An error occured");
}
} catch (err) {
console.log("err", err);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
if (startDate && endDate) {
@ -99,7 +169,7 @@ export default function CreateCalendarEventModal() {
</div>
<div className="max-w-[300px] flex-1 divide-y overflow-hidden rounded-md bg-muted">
<div className="flex justify-between p-0.5 px-2 pl-3">
<div className="flex w-[70px] shrink-0 items-center">Start</div>
<div className="flex w-[50px] shrink-0 items-center">Start</div>
<div className="flex-1">
<div className="flex max-w-full bg-secondary">
<DatePicker
@ -138,7 +208,7 @@ export default function CreateCalendarEventModal() {
</div>
</div>
<div className="flex justify-between p-0.5 px-2 pl-3">
<div className="flex w-[70px] shrink-0 items-center">End</div>
<div className="flex w-[50px] shrink-0 items-center">End</div>
<div className="flex-1">
<div className="flex max-w-full bg-secondary">
<DatePicker
@ -208,6 +278,24 @@ export default function CreateCalendarEventModal() {
</div>
</div>
</div>
<div className="w-full">
<Label className="text-muted-foreground">Event details</Label>
<Textarea
className="mt-1"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Some into about this event..."
/>
</div>
<div className="flex">
<Button
onClick={handleSubmit}
loading={isLoading}
className="w-full"
>
Submit
</Button>
</div>
</div>
</div>
</div>

View File

@ -51,8 +51,7 @@ export const getTagValues = (name: string, tags: string[][]) => {
export const getTagAllValues = (name: string, tags: string[][]) => {
const [itemTag] = tags.filter((tag: string[]) => tag[0] === name);
const itemValues = itemTag || [, undefined];
itemValues.shift();
return itemValues;
return itemValues.map((i, idx) => (idx ? i : undefined)).filter(Boolean);
};
export const getTagsValues = (name: string, tags: string[][]) => {
const itemTags = tags.filter((tag: string[]) => tag[0] === name);

View File

@ -80,6 +80,11 @@ export function formatDate(timestamp: Date, format?: string) {
dayjs.extend(timezone);
return dayjs(timestamp).format(format ?? "MMMM Do, YYYY");
}
export function formatDateUnix(timestamp: number, format?: string) {
dayjs.extend(advancedFormat);
dayjs.extend(timezone);
return dayjs(timestamp * 1000).format(format ?? "MMMM Do, YYYY");
}
export function convertToTimezoneDate(inputDate: Date, _timezone: string) {
dayjs.extend(utc);
dayjs.extend(timezone);