Merge pull request #1 from MutinyWallet/waitlist

Waitlist
This commit is contained in:
Paul Miller
2023-02-11 19:59:51 -06:00
committed by GitHub
16 changed files with 6399 additions and 3714 deletions

5949
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,14 +9,20 @@
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "^0.4.0",
"framer-motion": "^8.5.4",
"nostr-react": "^0.6.4",
"nostr-tools": "^1.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-modal-sheet": "^1.10.0"
"react-linkify": "1.0.0-alpha",
"react-modal-sheet": "^1.10.0",
"react-router-dom": "^6.8.0"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/react-linkify": "^1.0.1",
"@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
@@ -25,4 +31,4 @@
"vite": "^4.0.0",
"vite-plugin-pwa": "^0.14.1"
}
}
}

3603
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,115 +1,21 @@
import Sheet from 'react-modal-sheet';
import { useState } from 'react';
import logo from './assets/mutiny-logo.svg';
import mutiny_m from './assets/m.svg';
import scan from './assets/scan.svg';
import settings from './assets/settings.svg';
import send from './assets/send.svg';
function ActivityItem() {
return (
<div className="flex flex-row border-b border-gray-500 gap-4 py-2">
<img src={send} className="App-logo" alt="logo" />
<div className='flex flex-col flex-1'>
<h1>Bitcoin Beefsteak</h1>
<h2>-1,441,851 SAT</h2>
<h3 className='text-sm text-gray-500'>Jul 24</h3>
</div>
<div className='text-sm font-semibold uppercase text-[#E23A5E]'>SEND</div>
</div>
)
}
import { Routes, Route } from "react-router-dom";
import { AnimatePresence } from "framer-motion";
import Home from "./Home";
import Receive from "./Receive";
import Join from "./Join";
function App() {
const [isOpen, setOpen] = useState(false);
return (
<div className="safe-top safe-left safe-right safe-bottom">
<div className="disable-scrollbars max-h-screen h-full overflow-y-scroll mx-4">
<main className='flex flex-col gap-4 py-8'>
<header>
<img src={logo} className="App-logo" alt="logo" />
</header>
<div className='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Balance
</header>
<div>
<h1 className='text-4xl font-light'>
69,420 <span className='text-xl'>SAT</span>
</h1>
</div>
<div className="flex gap-2 py-4">
<button onClick={() => setOpen(true)} className='bg-[#1EA67F] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Send</span></button>
<button className='bg-[#3B6CCC] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Receive</span></button>
</div>
</div>
<div className='rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<div className='flex justify-end py-4'>
<a href="#" className='underline text-sm'>
MORE
</a>
</div>
</div>
{/* safety div */}
<div className="h-32" />
</main>
<div className="App">
{/* globals such as header will go here */}
</div>
<Sheet isOpen={isOpen} onClose={() => setOpen(false)}>
<Sheet.Container>
<Sheet.Header />
<Sheet.Content>
<div className='p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<h1 className='text-4xl font-light'>
It's a sheet! Like a modal, but a sheet.
</h1>
</div>
</Sheet.Content>
</Sheet.Container>
<Sheet.Backdrop />
</Sheet>
<nav className='bg-black fixed bottom-0 shadow-lg z-40 w-full safe-bottom'>
<ul className='h-16 flex justify-between px-16 items-center'>
<li className='h-full border-t-2 border-b-2 border-b-black flex flex-col justify-center'>
<img src={mutiny_m} className="App-logo" alt="logo" />
</li>
<li>
<img src={scan} className="App-logo" alt="logo" />
</li>
<li>
<img src={settings} className="App-logo" alt="logo" />
</li>
{/* <li>home</li> */}
{/* <li>scan</li> */}
{/* <li>settings</li> */}
</ul>
</nav>
</div >
)
<Routes>
{/* <Route path="/" element={<Home />} /> */}
<Route path="/" element={<Join />} />
</Routes>
</div>
);
}
export default App
export default App;

115
src/Home.tsx Normal file
View File

@@ -0,0 +1,115 @@
import Sheet from 'react-modal-sheet';
import { useState } from 'react';
import logo from './assets/mutiny-logo.svg';
import mutiny_m from './assets/m.svg';
import scan from './assets/scan.svg';
import settings from './assets/settings.svg';
import send from './assets/send.svg';
function ActivityItem() {
return (
<div className="flex flex-row border-b border-gray-500 gap-4 py-2">
<img src={send} className="App-logo" alt="logo" />
<div className='flex flex-col flex-1'>
<h1>Bitcoin Beefsteak</h1>
<h2>-1,441,851 SAT</h2>
<h3 className='text-sm text-gray-500'>Jul 24</h3>
</div>
<div className='text-sm font-semibold uppercase text-[#E23A5E]'>SEND</div>
</div>
)
}
function App() {
const [isOpen, setOpen] = useState(false);
return (
<div className="safe-top safe-left safe-right safe-bottom">
<div className="disable-scrollbars max-h-screen h-full overflow-y-scroll mx-4">
<main className='flex flex-col gap-4 py-8'>
<header>
<img src={logo} className="App-logo" alt="logo" />
</header>
<div className='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Balance
</header>
<div>
<h1 className='text-4xl font-light'>
69,420 <span className='text-xl'>SAT</span>
</h1>
</div>
<div className="flex gap-2 py-4">
<button onClick={() => setOpen(true)} className='bg-[#1EA67F] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Send</span></button>
<button className='bg-[#3B6CCC] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Receive</span></button>
</div>
</div>
<div className='rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<div className='flex justify-end py-4'>
<a href="#" className='underline text-sm'>
MORE
</a>
</div>
</div>
{/* safety div */}
<div className="h-32" />
</main>
</div>
<Sheet isOpen={isOpen} onClose={() => setOpen(false)}>
<Sheet.Container>
<Sheet.Header />
<Sheet.Content>
<div className='p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<h1 className='text-4xl font-light'>
It's a sheet! Like a modal, but a sheet.
</h1>
</div>
</Sheet.Content>
</Sheet.Container>
<Sheet.Backdrop />
</Sheet>
<nav className='bg-black fixed bottom-0 shadow-lg z-40 w-full safe-bottom'>
<ul className='h-16 flex justify-between px-16 items-center'>
<li className='h-full border-t-2 border-b-2 border-b-black flex flex-col justify-center'>
<img src={mutiny_m} className="App-logo" alt="logo" />
</li>
<li>
<img src={scan} className="App-logo" alt="logo" />
</li>
<li>
<img src={settings} className="App-logo" alt="logo" />
</li>
{/* <li>home</li> */}
{/* <li>scan</li> */}
{/* <li>settings</li> */}
</ul>
</nav>
</div >
)
}
export default App

34
src/Join.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { redirect } from "react-router-dom";
import button from "./components/button"
import WaitlistForm from "./components/WaitlistForm";
import { WaitlistAlreadyIn } from "./components/WaitlistAlreadyIn";
export default function Join() {
// On load, check if the user is already on the waitlist
const [waitlisted, setWaitlisted] = useState(false);
const [waitlistId, setWaitlistId] = useState(localStorage.getItem('waitlist_id') || "");
const [loading, setLoading] = useState(true);
// Fetch the waitlist status from the backend
useEffect(() => {
if (waitlistId) {
fetch(`http://127.0.0.1:8787/waitlist/${waitlistId}`).then(res => {
if (res.status === 200) {
setWaitlisted(true);
}
})
}
setLoading(false);
}, [waitlistId]);
return (
<div className="safe-top safe-left safe-right safe-bottom">
<div className="disable-scrollbars max-h-screen h-full overflow-y-scroll mx-4">
{waitlisted && !loading ? <WaitlistAlreadyIn /> : !loading ? <WaitlistForm /> : null}
</div>
</div >
)
}

19
src/Receive.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
function Receive() {
const pageMotion = {
initial: { opacity: 0, x: 0 },
animate: { opacity: 1, x: 50, transition: { duration: 2 } },
exit: { opacity: 0, x: 0, transition: { duration: 2 } }
};
return (
<div className="about">
<motion.div initial="initial" animate="animate" exit="exit" variants={pageMotion}>about page</motion.div>
<Link to="/">Go to home page</Link>
</div>
)
}
export default Receive

56
src/components/Notes.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { useNostrEvents } from "nostr-react";
import { nip19 } from 'nostr-tools'
import Linkify from 'react-linkify';
type NostrEvent = {
"content": string, "created_at": number, id?: string
}
function Note({ e }: { e: NostrEvent }) {
const date = new Date(e.created_at * 1000);
const linkRoot = "https://snort.social/e/";
let noteId;
if (e.id) {
noteId = nip19.noteEncode(e.id)
}
return (
<div className="flex gap-4 border-b border-faint-white py-6 items-start">
<img className="bg-black rounded-xl" src="../180.png" width={45} height={45} />
<div className="flex flex-col gap-2 max-w-sm">
{/* <p>{JSON.stringify(e, null, 2)}</p> */}
<p className="break-words">
<Linkify>
{e.content ?? e.content}
</Linkify>
</p>
<a className="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId}`}>
<small className="text-light-text">{date.toLocaleString()}</small>
</a>
</div>
</div>
)
}
export default function Notes() {
const { events } = useNostrEvents({
filter: {
authors: [
"0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33",
],
since: 0,
kinds: [1],
},
});
return (
<ul className="flex flex-col">
{events.filter((event) => !event.tags.length).map((event) => (
<li className="w-full" key={event.id}><Note e={event as NostrEvent} /></li>
))}
</ul>
)
}

View File

@@ -0,0 +1,28 @@
import Notes from "./Notes";
import { NostrProvider } from "nostr-react";
const relayUrls = [
"wss://nostr.zebedee.cloud",
"wss://relay.snort.social",
"wss://nos.lol",
"wss://brb.io",
"wss://nostr.fmt.wiz.biz",
"wss://relay.damus.io",
]
export function WaitlistAlreadyIn() {
return (
<main className='flex flex-col gap-4 py-8 max-w-xl mx-auto items-center drop-shadow-blue-glow'>
<h1 className="text-4xl font-bold">You're on a list!</h1>
<h2 className="text-xl">
We'll message you when Mutiny Wallet is ready.
</h2>
<div className="p-8 rounded-xl bg-half-black">
<h2 className="text-sm font-semibold uppercase">Recent Updates</h2>
<NostrProvider relayUrls={relayUrls} debug={true}>
<Notes />
</NostrProvider>
</div>
</main>
);
}

View File

@@ -0,0 +1,123 @@
import { useState } from "react";
import button from "./button";
const INPUT = "w-full mb-4 p-2 rounded-lg text-black"
const WAITLIST_ENDPOINT = "https://waitlist.mutiny-waitlist.workers.dev/waitlist";
// const WAITLIST_ENDPOINT = "http://localhost:8787/waitlist";
export default function WaitlistForm() {
let [nostr, setNostr] = useState(true);
let [error, setError] = useState<string | undefined>(undefined);
let [loading, setLoading] = useState(false);
// Form submission function that takes the form data and sends it to the backend
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(undefined);
setLoading(true);
const form = e.currentTarget;
const data = new FormData(form);
const value = Object.fromEntries(data.entries());
console.log(value);
let payload: null | { user_type: string, id: string, comment: string } = null;
if (nostr) {
payload = {
user_type: "nostr",
id: value.pubkey as string,
comment: value.comments as string
}
} else {
payload = {
user_type: "email",
id: value.email as string,
comment: value.comments as string
}
};
console.log(payload);
try {
if (!payload || !payload.id) {
throw new Error("nope");
}
let res = await fetch(WAITLIST_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (res.status !== 200) {
throw new Error("nope");
} else {
// On success set the id in local storage and reload the page
localStorage.setItem('waitlist_id', payload!.id);
window.location.reload();
}
} catch (e) {
if (nostr) {
setError("Something went wrong. Are you sure that's a valid npub?");
} else {
setError("Something went wrong. Are you sure that's a valid email?");
}
return
}
}
return (
<main className='flex flex-col gap-4 py-8 max-w-xl mx-auto drop-shadow-blue-glow'>
<h1 className='text-4xl font-bold'>Join Waitlist</h1>
{/* HTML form with three inputs: nostr pubkey (text), email (text), and a textarea for comments */}
<h2 className="text-xl">
Sign up for our waitlist and we'll send a message when Mutiny Wallet is ready for you.
</h2>
<div className="p-8 rounded-xl bg-half-black">
<div className="flex gap-4 mb-6">
<button className={button({ intent: nostr ? "active" : "inactive" })} onClick={() => setNostr(true)}><span className="drop-shadow-sm shadow-black">Nostr</span></button>
<button className={button({ intent: nostr ? "inactive" : "active" })} onClick={() => setNostr(false)}><span className="drop-shadow-sm shadow-black">Email</span></button>
</div>
{error &&
<div className="mb-6">
<p className="text-m-red">Error: {error}</p>
</div>
}
<form className="flex flex-col items-start gap-2" onSubmit={handleSubmit}>
{nostr &&
<>
<label className="font-semibold" htmlFor="pubkey">Nostr npub or NIP-05</label>
<input className={INPUT} type="text" id="pubkey" name="pubkey" placeholder="npub..." />
</>
}
{
!nostr &&
<>
<label className="font-semibold" htmlFor="email">Email</label>
<input className={INPUT} type="text" id="email" name="email" placeholder="email@mutinywallet.com" />
</>
}
<label className="font-semibold" htmlFor="comments">Comments</label>
<textarea className={INPUT} id="comments" name="comments" rows={4} placeholder="I want a lightning wallet that does..." />
{loading &&
<div role="status">
<svg aria-hidden="true" className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span className="sr-only">Loading...</span>
</div>}
{!loading &&
<button className={button({ intent: "red", layout: "pad" })}><span className="drop-shadow-sm shadow-black">Submit</span></button>
}
</form>
</div>
</main>
)
}

23
src/components/button.ts Normal file
View File

@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
variants: {
intent: {
active: "bg-white text-black",
inactive: "bg-black text-white border border-white",
blue: "bg-[#3B6CCC] text-white",
red: "bg-[#F61D5B] text-white"
},
layout: {
flex: "flex-1",
pad: "px-8"
},
},
defaultVariants: {
intent: "inactive",
layout: "flex"
},
});
export default button

1
src/decs.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "react-linkify"

View File

@@ -12,3 +12,7 @@ body {
.react-modal-sheet-container {
@apply !bg-[#262626];
}
a {
@apply underline decoration-light-text hover:decoration-white;
}

View File

@@ -1,10 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App'
import './index.css'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
},
]);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<RouterProvider router={router} />
{/* <App /> */}
</React.StrictMode>,
)

View File

@@ -6,11 +6,26 @@ module.exports = {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
variants: {
extend: {
borderWidth: ['responsive', 'last', 'hover', 'focus'],
}
},
theme: {
extend: {
colors: {
"half-black": "rgba(0, 0, 0, 0.5)",
"faint-white": "rgba(255, 255, 255, 0.1)",
"m-red": "#F61D5B",
"light-text": "rgba(250, 245, 234, 0.5)",
},
backgroundImage: {
'fade-to-blue': 'linear-gradient(1.63deg, #0B215B 32.05%, rgba(11, 33, 91, 0) 84.78%)'
},
dropShadow: {
'blue-glow': '0px 0px 32px rgba(11, 33, 91, 0.5)'
}
},
},
plugins: [