diff --git a/.DS_Store b/.DS_Store index d2e004d5e..c7a5f3616 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 000000000..415dac817 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/app.js b/src/app.js index 2f3557831..60ad8cd80 100644 --- a/src/app.js +++ b/src/app.js @@ -18,7 +18,7 @@ import Layout from "./components/layout"; // Magic/stripe import { magic } from "./lib/magic"; import { loadStripe } from "@stripe/stripe-js"; -import { ELements } from "@stripe/react-stripe-js"; +import { Elements } from "@stripe/react-stripe-js"; const promise = loadStripe(process.env.REACT_APP_STRIPE_PK_KEY); diff --git a/src/components/layout.js b/src/components/layout.js new file mode 100644 index 000000000..e854b9bb2 --- /dev/null +++ b/src/components/layout.js @@ -0,0 +1,23 @@ +import Header from "./header"; + +const Layout = ({ children }) => ( + <> +
+
+
{children}
+
+ + +); + +export default Layout; \ No newline at end of file diff --git a/src/components/loading.js b/src/components/loading.js new file mode 100644 index 000000000..f90ce846b --- /dev/null +++ b/src/components/loading.js @@ -0,0 +1,7 @@ +const Loading = () => ( +
+ Loading +
+ ); + + export default Loading; \ No newline at end of file diff --git a/src/components/login-form.js b/src/components/login-form.js new file mode 100644 index 000000000..359811893 --- /dev/null +++ b/src/components/login-form.js @@ -0,0 +1,53 @@ +// Note - update this with a highly modern, minimal form +import { useState } from 'react'; +import { Input, Icon, MonochromeIcons, CallToAction } from '@chakra-ui/react'; + +const LoginForm = ({ onEmailSubmit, disabled }) => { + const [email, setEmail] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + onEmailSubmit(email); + }; + + return ( + <> +
+

Log in

+
+ setEmail(e.target.value)} + prefix={} + /> +
+
+ + Login + +
+
+ + + ) +} + +export default LoginForm; \ No newline at end of file diff --git a/src/components/login.js b/src/components/login.js new file mode 100644 index 000000000..edfa72eda --- /dev/null +++ b/src/components/login.js @@ -0,0 +1,67 @@ +import { useState, useEffect, useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { magic } from "../lib/magic"; +import { UserContext } from "../lib/UserContext"; +import LoginForm from "./login-form"; + +const Login = () => { + const history = useHistory(); + const [disabled, setDisabled] = useState(false); + const [user, setUser] = useContext(UserContext); + + // If user is already logged in: redirect to profile page + useEffect(() => { + user && user.issuer && history.push("/profile"); + }, [user, history]); + + async function handleLoginWithEmail(email) { + try { + setDisabled(true); // Prevent multiple emails from being triggered by disabling the login button + + // Trigger Magic.Link email to user + let didToken = await magic.auth.loginWithMagicLink({ + email, + }); + + // Validate didToken with server + const res = await fetch(`${process.env.REACT_APP_SERVER_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + Authorization: "Bearer " + didToken, + }, + }); + + if (res.status === 200) { + let userMetadata = await magic.user.getMetadata(); // save information for the logged in user + await setUser(userMetadata); // UserContext is set to the logged in user + history.push('/profile'); + } + } catch (error) { + setDisabled(false); // Reenable login button (if the user requests to edit their email) + console.log(error); + } + } + + return ( + <> +
+ +
+ + + ); +}; + +export default Login; \ No newline at end of file diff --git a/src/components/payment-form.js b/src/components/payment-form.js new file mode 100644 index 000000000..88f107e92 --- /dev/null +++ b/src/components/payment-form.js @@ -0,0 +1,271 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js"; +import { useHistory } from "react-router-dom"; +import { LifetimeContext } from "../lib/LifetimeContext"; + +export default function PaymentForm({ email }) { + const [succeeded, setSucceeded] = useState(false); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(""); + const [disabled, setDisabled] = useState(true); + const [clientSecret, setClientSecret] = useState(""); + + const [customerId, setCustomerId] = useState(""); + const [, setLifetimeAccess] = useContext(LifetimeContext); + + const stripe = useStripe(); + const elements = useElements(); + const history = useHistory(); + + useEffect(() => { + // Create a paymentintent as soon as the page loads + fetch(`{process.env.REACT_APP_SERVER_URL}/create-payment-intent`, { + method: "POST", + headers: { + 'Content-Type': "application/json", + }, + body: JSON.stringify({ email }) + }) + .then((res) => { + return res.json(); + }) + .then((data) => { + setClientSecret(data.clientSecret); + setCustomerId(data.customer); + setLifetimeAccess(true); + }); + }, [email]); + + const cardStyle = { + style: { + base: { + color: "#32325d", + fontFamily: "Arial, sans-serif", + fontSmoothing: "antialiased", + fontSize: "16px", + "::placeholder": { + color: "#32325d", + }, + }, + invalid: { + color: "#fa755a", + iconColor: "#fa755a", + }, + }, + }; + + const handleChange = async (event) => { + // Listen for changes in the card element. Display errors as the user types card details + setDisabled(event.empty); + setError(event.error ? event.error.message : ""); + }; + + const handleSubmit = async (ev) => { + ev.preventDefault(); + setProcessing(true); + const payload = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: elements.getElement(CardElement), + }, + }); + + if (payload.error) { + setError(`Payment failed ${payload.error.message}`); + setProcessing(false); + } else { + setError(null); + setProcessing(false); + setSucceeded(true); + + // Update Stripe customer info to include metadata (determine if lifetimeAccess = true) + fetch(`${process.env.REACT_APP_SERVER_URL}/update-customer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ customerId }), + }) + .then((res) => { + return res.json(); + }) + .then((Data) => { + console.log("Updated Stripe customer object: ", data); + history.push("/premium-content"); + }); + } + }; + + return ( + <> +
+ + + {/* Show any error that happens when processing the payment */} + {error && ( +
+ {error}confirmCardPayment +
+ )} + + + + ); +} \ No newline at end of file diff --git a/src/components/payment.js b/src/components/payment.js new file mode 100644 index 000000000..571519d6a --- /dev/null +++ b/src/components/payment.js @@ -0,0 +1,41 @@ +import { useContext, useEffect } from "react"; +import { useHistory } from "react-router"; +import { UserContext } from "../lib/UserContext"; +import Loading from "./loading"; + +export default function Payment({ Elements, PaymentForm, promise }) { + const [user] = useContext(UserContext); + const history = useHistory(); + + // If not loading & no user found, redirect to /login page + useEffect(() => { + user && !user.loading && !user.issuer && history.push("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/login"); + }, [user, history]); + + return ( + <> +

+ Purchase Star Sailors to join the club +

+

+ Hi {user?.loading ? : user?.email}! You've signed up with your email account - now enter your card information. Currently, payment is conducted through our custom ERC20 token, or in Australian Dollars using the Stripe secure payment system +

+ {user?.loading ? ( + + ) : ( + + + + )} + + + ); +} \ No newline at end of file diff --git a/src/components/premium-content.js b/src/components/premium-content.js new file mode 100644 index 000000000..872b4c615 --- /dev/null +++ b/src/components/premium-content.js @@ -0,0 +1,105 @@ +import React, { useContext, useEffect } from "react"; +import { useHistory } from "react-router-dom"; +import { UserContext } from "../lib/UserContext"; +import { LifetimeContext } from "../lib/LifetimeContext"; +import { LifetimeAccessRequestStatusContext } from "../lib/LifetimeAccessRequestStatusContext"; +import { CallToAction } from "@magiclabs/ui"; + +const PremiumContent = () => { + const [user] = useContext(UserContext); + const [lifetimeAccess, setLifetimeAccess] = useContext(LifetimeContext); + const [ + lifetimeAccessRequestStatus, + setLifetimeAccessRequestStatus + ] = useContext(LifetimeAccessRequestStatusContext); + const history = useHistory(); + + // Check if the user has paid (and thus has the membership) + useEffect(() => { + if (user && !user.loading && user.issuer && !lifetimeAccessRequestStatus) { + setLifetimeAccessRequestStatus("Pending"); + window + .fetch(`${process.env.REACT_APP_SERVER_URL}/validate-customer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: user.email }), + }) + .then((res) => { + return res.json(); + }) + .then((data) => { + setLifetimeAccessRequestStatus("Complete"); + if ( + data.customer.length && + data.customer[0].metadata.lifetimeAccess === "true" + ) { + setLifetimeAccess(true); + } + }); + } + }); + + return ( + <> + { + // Display Premium Content if the user's logged in & have already paid for lifetime access + user && lifetimeAccess ? ( + <> +

Here's all of our content only available for our members!

+
♡ PREMIUM .
+
♡ PREMIUM .
+
♡ PREMIUM .
+
♡ PREMIUM .
+ + ) : ( + <> +

So you want our PREMIUM content? 😎

+
🧠 Our premium content includes some AWESOME stuff.
+ +
+ 🪄 To access the awesomeness, purchase a Lifetime Access Pass + below. +
+ +
💸 The Awesome Lifetime Access Pass is only $500!
+ { + // If the user is logged in, go straight to payment + user && user.issuer ? ( + history.push("/payment")} + > + Count Me In + + ) : ( + // Otherwise, ask the user to sign up first + history.push("/signup")} + > + Count Me In + + ) + } + + ) + } + + + ); +} + +export default PremiumContent; \ No newline at end of file diff --git a/src/components/profile.js b/src/components/profile.js new file mode 100644 index 000000000..9b5c54927 --- /dev/null +++ b/src/components/profile.js @@ -0,0 +1,45 @@ +import { useEffect, useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { UserContext } from "../lib/UserContext"; +import Loading from "./loading"; + +const Profile = () => { + const history = useHistory(); + const [user] = useContext(UserContext); + + // Redirect to login page if no [logged-in] user is found + useEffect(() => { + user && !user.loading && !user.issuer && history.push("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/login"); + }, [user, history]); + + return ( + <> + {user?.loading ? ( + + ) : ( + user?.issuer && ( + <> +
Email
+
{user.email}
+ +
User Id
+
{user.issuer}
+ + ) + )} + + + ); +}; + +export default Profile; \ No newline at end of file diff --git a/src/components/signup-form.js b/src/components/signup-form.js new file mode 100644 index 000000000..878c3bb9e --- /dev/null +++ b/src/components/signup-form.js @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { Input, Icon, MonochromeIcons, CallToAction } from "@magiclabs/ui"; + +const SignUpForm = ({ onEmailSubmit, disabled }) => { + const [email, setEmail] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + onEmailSubmit(email); + }; + + return ( + <> +
+

Sign Up

+
+ setEmail(e.target.value)} + prefix={} + /> +
+
+ + Sign up + +
+
+ + + ); +}; + +export default SignUpForm; \ No newline at end of file diff --git a/src/components/signup.js b/src/components/signup.js new file mode 100644 index 000000000..2dac68461 --- /dev/null +++ b/src/components/signup.js @@ -0,0 +1,72 @@ +import { useState, useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { magic } from "../lib/magic"; +import { UserContext } from "../lib/UserContext"; +import SignUpForm from "./signup-form"; + +const SignUp = () => { + const history = useHistory(); + const [disabled, setDisabled] = useState(false); + const [, setUser] = useContext(UserContext); + + async function handleSignUpWithEmail(email) { + try { + setDisabled(true); // Disable sign up button to prevent multiple emails from being triggered + + // Trigger Magic link to be sent to user + let didToken = await magic.auth.loginWithMagicLink({ + email, + }); + + // Validate didToken with server + const res = await fetch(`${process.env.REACT_APP_SERVER_URL}/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + didToken, + }, + }); + + if (res.status === 200) { + // Set the UserContext to the now signed up user + let userMetadata = await magic.user.getMetadata(); + await setUser(userMetadata); + history.push("/payment"); + } + } catch (error) { + setDisabled(false); // Re-enable sign up button - user may have requested to edit their email + console.log(error); + } + } + + return ( + <> +

Sign Up for Lifetime Access Pass 👩🏻‍💻

+

+ YAY! We're excited for you to sign up for a Lifetime Access Pass to + awesomeness. First, please sign in below to register your new account: +

+
+ +
+ + + ); +}; + +export default SignUp; \ No newline at end of file diff --git a/src/package.json b/src/package.json new file mode 100644 index 000000000..ac2dee924 --- /dev/null +++ b/src/package.json @@ -0,0 +1,44 @@ +{ + "name": "sk-club", + "version": "0.1.0", + "private": true, + "dependencies": { + "@magiclabs/ui": "0.8.0", + "@stripe/react-stripe-js": "^1.2.2", + "@stripe/stripe-js": "^1.12.1", + "magic-sdk": "^4.0.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", + "react-scripts": "4.0.1", + "web-vitals": "^0.2.4", + "yarn": "^1.22.10" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "framer-motion": "^2.3.0" + } + } \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 000000000..f3cbad1da --- /dev/null +++ b/src/server.js @@ -0,0 +1,94 @@ +require("dotenv").config(); // enables loading .env vars +const express = require("express"); +const app = express(); +const path = require("path"); +const cors = require("cors"); +const bodyParser = require("body-parser"); +app.use(bodyParser.json()); + +// Import, then initiate Magic instance for server-side methods +const { Magic } = require("@magic-sdk/admin"); +const magic = new Magic(process.env.MAGIC_SECRET_KEY); + +// Import & initiate Stripe instance +const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); + +// Allow requests from client-side +app.use(cors({ origin: process.env.CLIENT_URL })); + +// Route to validate the user's DID token +app.post("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/login", async (req, res) => { + try { + const didToken = req.headers.authorization.substr(7); + await magic.token.validate(didToken); + res.status(200).json({ authenticated: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Add the user to your list of customers +// Then create a PaymentIntent to track the customer's payment cycle +app.post("/create-payment-intent", async (req, res) => { + const { email } = req.body; + + const paymentIntent = await stripe.customers + .create({ + email, + }) + .then((customer) => + stripe.paymentIntents + .create({ + amount: 50000, // Replace this constant with the price of your service + currency: "usd", + customer: customer.id, + }) + .catch((error) => console.log("error: ", error)) + ); + + res.send({ + clientSecret: paymentIntent.client_secret, + customer: paymentIntent.customer, + }); +}); + +// Update the customer's info to reflect that they've +// paid for lifetime access to your Premium Content +app.post("/update-customer", async (req, res) => { + const { customerID } = req.body; + + const customer = await stripe.customers.update(customerID, { + metadata: { lifetimeAccess: true }, + }); + + res.send({ + customer, + }); +}); + +// Collect the customer's information to help validate +// that they've paid for lifetime access +app.post("/validate-customer", async (req, res) => { + const { email } = req.body; + + const customer = await stripe.customers.list({ + limit: 1, + email, + }); + + res.send({ + customer: customer.data, + }); +}); + +/* For heroku deployment */ +if (process.env.NODE_ENV === "production") { + app.use(express.static("client/build")); + app.get("*", (req, res) => { + res.sendFile(path.resolve(__dirname, "client", "build", "index.html")); + }); +} + +const listener = app.listen(process.env.PORT || 8080, function () { + console.log("Listening on port " + listener.address().port); +}); \ No newline at end of file