Typescript first integration + fix all eslint errors (97 warnings)

This commit is contained in:
Michael
2021-08-10 22:03:23 +02:00
parent 8d69135404
commit 2cccdba402
55 changed files with 10477 additions and 2308 deletions

View File

@@ -6,8 +6,9 @@
"cypress/globals": true "cypress/globals": true
}, },
"extends": [ "extends": [
"plugin:react/recommended", "next",
"airbnb", "next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"prettier" "prettier"
], ],
"parserOptions": { "parserOptions": {
@@ -18,7 +19,7 @@
"sourceType": "module" "sourceType": "module"
}, },
"plugins": [ "plugins": [
"react", "@typescript-eslint",
"cypress", "cypress",
"simple-import-sort", "simple-import-sort",
"prettier" "prettier"
@@ -26,7 +27,12 @@
"rules": { "rules": {
"no-console": "off", "no-console": "off",
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"prettier/prettier": "error", "prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
2, 2,

View File

@@ -1,5 +1,9 @@
{ {
"semi": true,
"trailingComma": "es5",
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"semi": true "useTabs": true,
"endOfLine": "auto",
"printWidth": 100
} }

View File

@@ -2,22 +2,18 @@ import Image from 'next/image';
import authImage from 'public/auth.png'; import authImage from 'public/auth.png';
const AuthText = () => ( const AuthText = () => (
<div className="lg:mt-0 max-w-lg flex flex-col text-xl"> <div className="lg:mt-0 max-w-lg flex flex-col text-xl">
<div className="mt-10 mb-3 m-auto"> <div className="mt-10 mb-3 m-auto">
<Image <Image src={authImage} width={authImage.width / 1.5} height={authImage.height / 1.5} />
src={authImage} </div>
width={authImage.width / 1.5} <h2 className="text-4xl font-title font-semibold text-center">
height={authImage.height / 1.5} Join SupaNexTail for <span className="text-primary">free</span>!
/> </h2>
</div> <p className="mb-5 mt-8 leading-9">
<h2 className="text-4xl font-title font-semibold text-center"> Create your website in a few minutes with our boilerplate. You can use the login system, this
Join SupaNexTail for <span className="text-primary">free</span>! will allow you to discover the sample dashboard page.
</h2> </p>
<p className="mb-5 mt-8 leading-9"> </div>
Create your website in a few minutes with our boilerplate. You can use the
login system, this will allow you to discover the sample dashboard page.
</p>
</div>
); );
export default AuthText; export default AuthText;

View File

@@ -6,99 +6,105 @@ You can tweak the max size, line 47
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Image from 'next/image';
import { supabase } from 'utils/supabaseClient'; import { supabase } from 'utils/supabaseClient';
const Avatar = ({ url, size, onUpload }) => { const Avatar = ({ url, size, onUpload }) => {
const [avatarUrl, setAvatarUrl] = useState(null); const [avatarUrl, setAvatarUrl] = useState(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { const customImgLoader = ({ src }) => {
if (url) downloadImage(url); return `${src}`;
}, [url]); };
async function downloadImage(path) { useEffect(() => {
try { if (url) downloadImage(url);
const { data, error } = await supabase.storage }, [url]);
.from('avatars')
.download(path);
if (error) {
throw error;
}
const url = URL.createObjectURL(data);
setAvatarUrl(url);
} catch (error) {
console.log('Error downloading image: ', error.message);
}
}
async function uploadAvatar(event) { async function downloadImage(path) {
try { try {
setUploading(true); const { data, error } = await supabase.storage.from('avatars').download(path);
if (error) {
throw error;
}
const url = URL.createObjectURL(data);
setAvatarUrl(url);
} catch (error) {
console.log('Error downloading image: ', error.message);
}
}
if (!event.target.files || event.target.files.length === 0) { async function uploadAvatar(event) {
throw new Error('You must select an image to upload.'); try {
} setUploading(true);
const file = event.target.files[0]; if (!event.target.files || event.target.files.length === 0) {
const fileExt = file.name.split('.').pop(); throw new Error('You must select an image to upload.');
const fileName = `${Math.random()}.${fileExt}`; }
const filePath = `${fileName}`;
if (event.target.files[0].size > 150000) { const file = event.target.files[0];
alert('File is too big!'); const fileExt = file.name.split('.').pop();
event.target.value = ''; const fileName = `${Math.random()}.${fileExt}`;
setUploading(false); const filePath = `${fileName}`;
return;
}
const { error: uploadError } = await supabase.storage if (event.target.files[0].size > 150000) {
.from('avatars') alert('File is too big!');
.upload(filePath, file); event.target.value = '';
setUploading(false);
return;
}
if (uploadError) { const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file);
throw uploadError;
}
onUpload(filePath); if (uploadError) {
} catch (error) { throw uploadError;
alert(error.message); }
} finally {
setUploading(false);
}
}
return ( onUpload(filePath);
<div className="m-auto mb-5"> } catch (error) {
{avatarUrl ? ( alert(error.message);
<img } finally {
src={avatarUrl} setUploading(false);
alt="Avatar" }
className="avatar rounded-full w-28 h-28 flex m-auto" }
/>
) : ( return (
<div className="avatar rounded-full w-28 h-28" /> <div className="m-auto mb-5">
)} {avatarUrl ? (
<div style={{ width: size }}> <div className="w-full flex justify-center">
<label <Image
className="mt-2 btn btn-primary text-center cursor-pointer text-xs btn-sm" loader={customImgLoader} //Using custom loader because of this issue https://github.com/vercel/next.js/discussions/19732
htmlFor="single" src={avatarUrl}
> height={100}
{uploading ? 'Uploading ...' : 'Update my avatar'} width={100}
</label> alt="Avatar"
<input className="avatar rounded-full w-28 h-28"
style={{ />
visibility: 'hidden', </div>
position: 'absolute', ) : (
}} <div className="avatar rounded-full w-28 h-28" />
type="file" )}
id="single" <div style={{ width: size }}>
accept="image/*" <label
onChange={uploadAvatar} className="mt-2 btn btn-primary text-center cursor-pointer text-xs btn-sm"
disabled={uploading} htmlFor="single"
/> >
</div> {uploading ? 'Uploading ...' : 'Update my avatar'}
</div> </label>
); <input
style={{
visibility: 'hidden',
position: 'absolute',
}}
type="file"
id="single"
accept="image/*"
onChange={uploadAvatar}
disabled={uploading}
/>
</div>
</div>
);
}; };
export default Avatar; export default Avatar;

View File

@@ -8,53 +8,48 @@ import cardStripe from 'public/landing/stripe.svg';
import cardTheme from 'public/landing/theme.svg'; import cardTheme from 'public/landing/theme.svg';
const CardsLanding = () => ( const CardsLanding = () => (
<div className="mt-14"> <div className="mt-14">
<h2 className="uppercase font-bold text-4xl tracking-wide text-center"> <h2 className="uppercase font-bold text-4xl tracking-wide text-center">
We've got you covered We've got you covered
</h2> </h2>
<p className="max-w-md m-auto text-center"> <p className="max-w-md m-auto text-center">
Dont waste your time and reinvent the wheel, we have provided you with a Dont waste your time and reinvent the wheel, we have provided you with a maximum of features
maximum of features so that you only have one goal, to make your SaaS a so that you only have one goal, to make your SaaS a reality.
reality. </p>
</p> <div className="flex flex-wrap justify-center mt-10">
<div className="flex flex-wrap justify-center mt-10"> <CardLanding
<CardLanding image={cardPage}
image={cardPage} text="7 pages fully designed and easily customizable"
text="7 pages fully designed and easily customizable" title="Templates"
title="Templates" />
/> <CardLanding
<CardLanding image={cardServer}
image={cardServer} text="Integrated backend already setup with Next.js API Routes"
text="Integrated backend already setup with Next.js API Routes" title="Backend"
title="Backend" />
/> <CardLanding image={cardAuth} text="Auth and user management with Supabase" title="Auth" />
<CardLanding <CardLanding
image={cardAuth} image={cardResponsive}
text="Auth and user management with Supabase" text="Mobile ready, fully responsive and customizable with Tailwind CSS"
title="Auth" title="Responsive"
/> />
<CardLanding <CardLanding
image={cardResponsive} image={cardTheme}
text="Mobile ready, fully responsive and customizable with Tailwind CSS" text="Custom themes available and easily switch to dark mode"
title="Responsive" title="Themes"
/> />
<CardLanding <CardLanding
image={cardTheme} image={cardStripe}
text="Custom themes available and easily switch to dark mode" text="Stripe integration. Fully functional subscription system"
title="Themes" title="Payment"
/> />
<CardLanding <CardLanding
image={cardStripe} image={cardFee}
text="Stripe integration. Fully functional subscription system" text="One-time fee. No subscription, youll have access to all the updates"
title="Payment" title="Lifetime access"
/> />
<CardLanding </div>
image={cardFee} </div>
text="One-time fee. No subscription, youll have access to all the updates"
title="Lifetime access"
/>
</div>
</div>
); );
export default CardsLanding; export default CardsLanding;

View File

@@ -10,92 +10,92 @@ import axios from 'axios';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const Contact = () => { const Contact = () => {
const sendEmail = () => { const sendEmail = () => {
const name = document.getElementById('name').value; const name = document.getElementById('name').value;
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
const message = document.getElementById('message').value; const message = document.getElementById('message').value;
if (name && email && message) { if (name && email && message) {
axios axios
.post('/api/sendgrid', { email, name, message }) .post('/api/sendgrid', { email, name, message })
.then((result) => { .then((result) => {
if (result.data.success === true) { if (result.data.success === true) {
toast.success(result.data.message); toast.success(result.data.message);
document.getElementById('name').value = ''; document.getElementById('name').value = '';
document.getElementById('email').value = ''; document.getElementById('email').value = '';
document.getElementById('message').value = ''; document.getElementById('message').value = '';
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
} else { } else {
toast.info('Please enter at least one URL', { toast.info('Please enter at least one URL', {
position: 'top-center', position: 'top-center',
autoClose: 2000, autoClose: 2000,
hideProgressBar: true, hideProgressBar: true,
closeOnClick: true, closeOnClick: true,
pauseOnHover: true, pauseOnHover: true,
draggable: true, draggable: true,
progress: undefined, progress: undefined,
}); });
} }
}; };
return ( return (
<div className="max-w-xl m-auto px-5 py-10"> <div className="max-w-xl m-auto px-5 py-10">
<div> <div>
<div className="flex justify-center"> <div className="flex justify-center">
<h2 className="text-3xl sm:text-4xl text-center mb-5 mt-0 font-bold font-title"> <h2 className="text-3xl sm:text-4xl text-center mb-5 mt-0 font-bold font-title">
Contact Contact
</h2> </h2>
</div> </div>
<p className="m-auto text-center"> <p className="m-auto text-center">
Do you have a question about SupaNexTail? A cool feature you'd like us Do you have a question about SupaNexTail? A cool feature you'd like us to integrate? A bug
to integrate? A bug to report? Don't hesitate! to report? Don't hesitate!
</p> </p>
</div> </div>
<form className="m-auto mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 p-5"> <form className="m-auto mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 p-5">
<div className="flex flex-col max-w-xs"> <div className="flex flex-col max-w-xs">
<p className="font-light mb-4 text-left">Your Name</p> <p className="font-light mb-4 text-left">Your Name</p>
<input <input
id="name" id="name"
name="name" name="name"
placeholder="Enter your name" placeholder="Enter your name"
className="input input-primary input-bordered" className="input input-primary input-bordered"
/> />
</div> </div>
<div className="flex flex-col max-w-xs mb-3"> <div className="flex flex-col max-w-xs mb-3">
<p className="font-light mb-4 text-left">Your email</p> <p className="font-light mb-4 text-left">Your email</p>
<input <input
id="email" id="email"
name="email" name="email"
placeholder="Enter your email adress" placeholder="Enter your email adress"
className="input input-primary input-bordered" className="input input-primary input-bordered"
/> />
</div> </div>
<div className="flex flex-col col-span-full w-fulll"> <div className="flex flex-col col-span-full w-fulll">
<p className="font-light mb-4 text-left">Message</p> <p className="font-light mb-4 text-left">Message</p>
<textarea <textarea
id="message" id="message"
name="message" name="message"
placeholder="Enter your message here..." placeholder="Enter your message here..."
rows="5" rows="5"
className="input input-primary input-bordered resize-none w-full h-32 pt-2" className="input input-primary input-bordered resize-none w-full h-32 pt-2"
/> />
</div> </div>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
sendEmail(); sendEmail();
}} }}
> >
Submit{' '} Submit{' '}
</button> </button>
</form> </form>
</div> </div>
); );
}; };
export default Contact; export default Contact;

View File

@@ -17,117 +17,115 @@ import PaymentModal from './PaymentModal';
import Avatar from './Avatar'; import Avatar from './Avatar';
export default function Dashboard(props) { export default function Dashboard(props) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [username, setUsername] = useState(props.profile.username); const [username, setUsername] = useState(props.profile.username);
const [website, setWebsite] = useState(props.profile.website); const [website, setWebsite] = useState(props.profile.website);
const [avatar_url, setAvatarUrl] = useState(props.profile.avatar_url); const [avatar_url, setAvatarUrl] = useState(props.profile.avatar_url);
const [payment, setPayment] = useState(false); const [payment, setPayment] = useState(false);
useEffect(() => { useEffect(() => {
if (router.query.session_id && router.query.session_id !== 'canceled') { if (router.query.session_id && router.query.session_id !== 'canceled') {
setPayment(true); setPayment(true);
} }
}, []); }, []);
async function updateProfile({ username, website, avatar_url }) { async function updateProfile({ username, website, avatar_url }) {
try { try {
setLoading(true); setLoading(true);
const user = supabase.auth.user(); const user = supabase.auth.user();
const updates = { const updates = {
id: user.id, id: user.id,
username, username,
website, website,
avatar_url, avatar_url,
updated_at: new Date(), updated_at: new Date(),
}; };
const { error } = await supabase.from('profiles').upsert(updates, { const { error } = await supabase.from('profiles').upsert(updates, {
returning: 'minimal', // Don't return the value after inserting returning: 'minimal', // Don't return the value after inserting
}); });
if (error) { if (error) {
throw error; throw error;
} }
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} finally { } finally {
setLoading(false); setLoading(false);
toast.success('Your profile has been updated'); toast.success('Your profile has been updated');
} }
} }
return ( return (
<div className="flex flex-col text-left w-full max-w-xl m-auto px-5 py-10"> <div className="flex flex-col text-left w-full max-w-xl m-auto px-5 py-10">
<div className="max-w-sm flex flex-col justify-center m-auto w-full p-5"> <div className="max-w-sm flex flex-col justify-center m-auto w-full p-5">
<h1 className="text-4xl font-bold md:text-5xl font-title text-center mb-10"> <h1 className="text-4xl font-bold md:text-5xl font-title text-center mb-10">Dashboard</h1>
Dashboard <Avatar
</h1> url={avatar_url}
<Avatar size={150}
url={avatar_url} onUpload={(url) => {
size={150} setAvatarUrl(url);
onUpload={(url) => { updateProfile({ username, website, avatar_url: url });
setAvatarUrl(url); }}
updateProfile({ username, website, avatar_url: url }); />
}} <div className="mb-5 flex flex-col">
/> <label htmlFor="email" className="my-auto text-sm mb-2">
<div className="mb-5 flex flex-col"> Email
<label htmlFor="email" className="my-auto text-sm mb-2"> </label>
Email <input
</label> className="input input-primary input-bordered input-sm flex-1 text-base-100"
<input id="email"
className="input input-primary input-bordered input-sm flex-1 text-base-100" type="text"
id="email" value={props.session.user.email}
type="text" disabled
value={props.session.user.email} />
disabled </div>
/> <div className="mb-5 flex flex-col">
</div> <label htmlFor="username" className="my-auto text-sm mb-2">
<div className="mb-5 flex flex-col"> Name
<label htmlFor="username" className="my-auto text-sm mb-2"> </label>
Name <input
</label> className="input input-primary input-bordered input-sm flex-1"
<input id="username"
className="input input-primary input-bordered input-sm flex-1" type="text"
id="username" value={username || ''}
type="text" onChange={(e) => setUsername(e.target.value)}
value={username || ''} />
onChange={(e) => setUsername(e.target.value)} </div>
/> <div className="mb-5 flex flex-col">
</div> <label htmlFor="website" className="my-auto text-sm mb-2">
<div className="mb-5 flex flex-col"> Website
<label htmlFor="website" className="my-auto text-sm mb-2"> </label>
Website <input
</label> className="input input-primary input-bordered input-sm flex-1"
<input id="website"
className="input input-primary input-bordered input-sm flex-1" type="website"
id="website" value={website || ''}
type="website" onChange={(e) => setWebsite(e.target.value)}
value={website || ''} />
onChange={(e) => setWebsite(e.target.value)} </div>
/>
</div>
<div className="m-auto"> <div className="m-auto">
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => updateProfile({ username, website, avatar_url })} onClick={() => updateProfile({ username, website, avatar_url })}
disabled={loading} disabled={loading}
> >
{loading ? 'Loading ...' : 'Update My Profile'} {loading ? 'Loading ...' : 'Update My Profile'}
</button> </button>
</div> </div>
</div> </div>
<div className="max-w-xl flex flex-row flex-wrap m-auto w-full p-5 bordered border-2 border-primary shadow-lg my-5"> <div className="max-w-xl flex flex-row flex-wrap m-auto w-full p-5 bordered border-2 border-primary shadow-lg my-5">
<Image src={Plan} /> <Image src={Plan} />
<div className="flex flex-col m-auto"> <div className="flex flex-col m-auto">
<h2>Your current plan</h2> <h2>Your current plan</h2>
<p className="">{props.plan ? PriceIds[props.plan] : 'Free tier'}</p> <p className="">{props.plan ? PriceIds[props.plan] : 'Free tier'}</p>
</div> </div>
</div> </div>
<PaymentModal open={payment} setPayment={setPayment} /> <PaymentModal open={payment} setPayment={setPayment} />
</div> </div>
); );
} }

View File

@@ -2,27 +2,27 @@ import Link from 'next/link';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const Footer = () => { const Footer = () => {
const ThemeToggle = dynamic(() => import('components/UI/ThemeToggle.js'), { const ThemeToggle = dynamic(() => import('components/UI/ThemeToggle.js'), {
ssr: false, ssr: false,
}); });
return ( return (
<footer className="w-full flex"> <footer className="w-full flex">
<nav className=" mr-auto"> <nav className=" mr-auto">
<div className="flex flex-col sm:flex-row justify-evenly w-full sm:space-x-10"> <div className="flex flex-col sm:flex-row justify-evenly w-full sm:space-x-10">
<div className="">© {process.env.NEXT_PUBLIC_TITLE}</div> <div className="">© {process.env.NEXT_PUBLIC_TITLE}</div>
<Link href="/privacy"> <Link href="/privacy">
<a>Privacy Policy</a> <a>Privacy Policy</a>
</Link> </Link>
<Link href="/terms"> <Link href="/terms">
<a>Terms of service</a> <a>Terms of service</a>
</Link> </Link>
</div> </div>
</nav> </nav>
<div className="mr-5 my-auto"> <div className="mr-5 my-auto">
<ThemeToggle /> <ThemeToggle />
</div> </div>
</footer> </footer>
); );
}; };
export default Footer; export default Footer;

View File

@@ -6,60 +6,57 @@ import supabaseImage from 'public/landing/supabase.svg';
import MailingList from './MailingList'; import MailingList from './MailingList';
const Landing = () => ( const Landing = () => (
<div className="mt-10 mb-20 text-base-content w-full"> <div className="mt-10 mb-20 text-base-content w-full">
<div className="flex max-w-6xl m-auto justify-around"> <div className="flex max-w-6xl m-auto justify-around">
<div className="max-w-sm mr-16 my-auto"> <div className="max-w-sm mr-16 my-auto">
<h2 className="text-4xl font-bold font-title text-left leading-normal"> <h2 className="text-4xl font-bold font-title text-left leading-normal">
Build your <span className="text-primary">SaaS</span> in the blink of Build your <span className="text-primary">SaaS</span> in the blink of an eye!
an eye! </h2>
</h2> <p>
<p> SupaNexTail got your back, and takes care of the initial setup, sometimes time consuming,
SupaNexTail got your back, and takes care of the initial setup, but essential to your success.
sometimes time consuming, but essential to your success. </p>
</p> </div>
</div> <div className="max-w-xl">
<div className="max-w-xl"> <Image src={landTop} height={417} width={583} />
<Image src={landTop} height={417} width={583} /> </div>
</div> </div>
</div>
<CardsLanding /> <CardsLanding />
<div className="flex max-w-6xl m-auto justify-around mt-14 flex-wrap"> <div className="flex max-w-6xl m-auto justify-around mt-14 flex-wrap">
<div className="max-w-sm mr-16 my-auto"> <div className="max-w-sm mr-16 my-auto">
<h2 className="text-4xl font-bold font-title text-left leading-normal"> <h2 className="text-4xl font-bold font-title text-left leading-normal">
All you need to start <span className="text-primary">now</span> All you need to start <span className="text-primary">now</span>
</h2> </h2>
<p> <p>
SupaNexTail got your back, and takes care of the initial setup, SupaNexTail got your back, and takes care of the initial setup, sometimes time consuming,
sometimes time consuming, but essential to your success. but essential to your success.
</p> </p>
</div> </div>
<div className="max-w-xl"> <div className="max-w-xl">
<Image src={start} /> <Image src={start} />
</div> </div>
</div> </div>
<div className="flex max-w-6xl m-auto justify-around mt-24 flex-wrap"> <div className="flex max-w-6xl m-auto justify-around mt-24 flex-wrap">
<div className="max-w-md my-auto order-1 lg:order-2"> <div className="max-w-md my-auto order-1 lg:order-2">
<h2 className="text-4xl font-bold font-title text-left leading-normal"> <h2 className="text-4xl font-bold font-title text-left leading-normal">
Leverage the power of <span className="text-primary">Supabase</span> Leverage the power of <span className="text-primary">Supabase</span>
</h2> </h2>
<p> <p>
Supabase is an open source Firebase alternative. Youll have a Supabase is an open source Firebase alternative. Youll have a database, an auth system, a
database, an auth system, a storage system, and much more in one storage system, and much more in one product.
product. </p>
</p> <p>
<p> SupaNexTail uses Supabase at its core, and preconfigures all the useful elements for your
SupaNexTail uses Supabase at its core, and preconfigures all the site. User registration, synchronization with Stripe, weve got you covered!
useful elements for your site. User registration, synchronization with </p>
Stripe, weve got you covered! </div>
</p> <div className="max-w-md order-2 lg:order-1 flex">
</div> <Image src={supabaseImage} />
<div className="max-w-md order-2 lg:order-1 flex"> </div>
<Image src={supabaseImage} /> </div>
</div> <MailingList />
</div> </div>
<MailingList />
</div>
); );
export default Landing; export default Landing;

View File

@@ -19,57 +19,43 @@ import Nav from './Nav';
import Footer from './Footer'; import Footer from './Footer';
const Layout = (props) => { const Layout = (props) => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const toastStyle = { const toastStyle = {
// Style your toast elements here // Style your toast elements here
success: 'bg-accent', success: 'bg-accent',
error: 'bg-red-600', error: 'bg-red-600',
info: 'bg-gray-600', info: 'bg-gray-600',
warning: 'bg-orange-400', warning: 'bg-orange-400',
default: 'bg-primary', default: 'bg-primary',
dark: 'bg-white-600 font-gray-300', dark: 'bg-white-600 font-gray-300',
}; };
return ( return (
<div className="min-h-screen w-full bg-base-100 text-base-content m-auto font-body"> <div className="min-h-screen w-full bg-base-100 text-base-content m-auto font-body">
<Head> <Head>
<link <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
rel="apple-touch-icon" <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
sizes="180x180" <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
href="/apple-touch-icon.png" <link rel="manifest" href="/site.webmanifest" />
/> <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<link <meta name="msapplication-TileColor" content="#da532c" />
rel="icon" <meta name="theme-color" content="#ffffff" />
type="image/png" </Head>
sizes="32x32" <div className="max-w-7xl flex flex-col min-h-screen mx-auto p-5">
href="/favicon-32x32.png" <Nav user={user} signOut={signOut} />
/> <main className="flex-1">{props.children}</main>
<link <ToastContainer
rel="icon" position="bottom-center"
type="image/png" toastClassName={({ type }) =>
sizes="16x16" `${
href="/favicon-16x16.png" toastStyle[type || 'default']
/> } flex p-5 my-5 min-h-10 rounded-md justify-between overflow-hidden cursor-pointer `
<link rel="manifest" href="/site.webmanifest" /> }
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" /> />
<meta name="msapplication-TileColor" content="#da532c" /> <Footer />
<meta name="theme-color" content="#ffffff" /> </div>
</Head> </div>
<div className="max-w-7xl flex flex-col min-h-screen mx-auto p-5"> );
<Nav user={user} signOut={signOut} />
<main className="flex-1">{props.children}</main>
<ToastContainer
position="bottom-center"
toastClassName={({ type }) =>
`${
toastStyle[type || 'default']
} flex p-5 my-5 min-h-10 rounded-md justify-between overflow-hidden cursor-pointer `
}
/>
<Footer />
</div>
</div>
);
}; };
export default Layout; export default Layout;

View File

@@ -10,77 +10,73 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
const MailingList = () => { const MailingList = () => {
const [mail, setMail] = useState(null); const [mail, setMail] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [valid, setValid] = useState(true); const [valid, setValid] = useState(true);
const validateEmail = () => { const validateEmail = () => {
// Regex patern for email validation // Regex patern for email validation
const regex = const regex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (regex.test(mail)) { if (regex.test(mail)) {
// this is a valid email address // this is a valid email address
subscribe(); subscribe();
setValid(true); setValid(true);
} else { } else {
// invalid email. // invalid email.
toast.error('Your email is invalid'); toast.error('Your email is invalid');
setValid(false); setValid(false);
} }
}; };
const subscribe = () => { const subscribe = () => {
setLoading(true); setLoading(true);
axios axios
.put('api/mailingList', { .put('api/mailingList', {
mail, mail,
}) })
.then((result) => { .then((result) => {
if (result.status === 200) { if (result.status === 200) {
toast.success(result.data.message); toast.success(result.data.message);
setLoading(false); setLoading(false);
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
setLoading(false); setLoading(false);
}); });
}; };
return ( return (
<div className="my-10 mt-24 m-auto flex flex-col"> <div className="my-10 mt-24 m-auto flex flex-col">
<h2 className="text-3xl md:text-4xl font-bold font-title uppercase text-center"> <h2 className="text-3xl md:text-4xl font-bold font-title uppercase text-center">
Stay Tuned Stay Tuned
</h2> </h2>
<Image src={Mailing} /> <Image src={Mailing} />
<label className="label"> <label className="label">
<p className="text-center max-w-md m-auto"> <p className="text-center max-w-md m-auto">
Want to be the first to know when SupaNexTail launches and get an Want to be the first to know when SupaNexTail launches and get an exclusive discount? Sign
exclusive discount? Sign up for the newsletter! up for the newsletter!
</p> </p>
</label> </label>
<div className="mt-5 m-auto"> <div className="mt-5 m-auto">
<input <input
onChange={(e) => { onChange={(e) => {
setMail(e.target.value); setMail(e.target.value);
}} }}
type="email" type="email"
placeholder="Your email" placeholder="Your email"
className={`input input-primary input-bordered ${ className={`input input-primary input-bordered ${valid ? null : 'input-error'}`}
valid ? null : 'input-error' />
}`} <button
/> onClick={validateEmail}
<button className={`btn ml-3 ${loading ? 'btn-disabled loading' : 'btn-primary'}`}
onClick={validateEmail} >
className={`btn ml-3 ${ I'm in!
loading ? 'btn-disabled loading' : 'btn-primary' </button>
}`} </div>
> </div>
I'm in! );
</button>
</div>
</div>
);
}; };
export default MailingList; export default MailingList;

View File

@@ -1,74 +0,0 @@
/*
This is your Nav component. It contain a responsive navbar
*/
import { LogOut, Menu } from 'react-feather';
import Image from 'next/image';
import Link from 'next/link';
import Logo from 'public/logo.svg';
const Nav = (props) => {
// Modify you menu directly here
const NavMenu = (
<>
{props.user && (
<Link href="/dashboard">
<a className="nav-btn">Dashboard</a>
</Link>
)}
<Link href="/pricing">
<a className="nav-btn">Pricing</a>
</Link>
<Link href="/contact">
<a className="nav-btn">Contact Us</a>
</Link>
{props.user ? (
<button className="btn btn-xs text-xs" onClick={() => props.signOut()}>
<LogOut size={12} className="mr-2" />
Logout
</button>
) : (
<>
<Link href="/login">
<a className="nav-btn">Login</a>
</Link>
<Link href="/signup">
<a className="btn btn-sm btn-primary font-body normal-case font-normal">
Sign Up
</a>
</Link>
</>
)}
</>
);
return (
<nav className="navbar mb-2 w-full">
<Link href="/">
<a>
<Image src={Logo} />
</a>
</Link>
<div className="hidden lg:flex text-center flex-col lg:flex-row lg:space-x-10 font-body text-sm ml-auto">
{NavMenu}
</div>
<div className="ml-auto lg:hidden">
<div className="dropdown dropdown-end" data-cy="dropdown">
<div tabIndex="0" className="m-1 cursor-pointer">
<Menu />
</div>
<div className="menu dropdown-content mt-3 text-center space-y-3 w-24">
{NavMenu}
</div>
</div>
</div>
</nav>
);
};
export default Nav;

70
components/Nav.tsx Normal file
View File

@@ -0,0 +1,70 @@
/*
This is your Nav component. It contain a responsive navbar
*/
import { LogOut, Menu } from 'react-feather';
import Image from 'next/image';
import Link from 'next/link';
import Logo from 'public/logo.svg';
const Nav = (props: any) => {
// Modify you menu directly here
const NavMenu = (
<>
{props.user && (
<Link href="/dashboard">
<a className="nav-btn">Dashboard</a>
</Link>
)}
<Link href="/pricing">
<a className="nav-btn">Pricing</a>
</Link>
<Link href="/contact">
<a className="nav-btn">Contact Us</a>
</Link>
{props.user ? (
<button className="btn btn-xs text-xs" onClick={() => props.signOut()}>
<LogOut size={12} className="mr-2" />
Logout
</button>
) : (
<>
<Link href="/login">
<a className="nav-btn">Login</a>
</Link>
<Link href="/signup">
<a className="btn btn-sm btn-primary font-body normal-case font-normal">Sign Up</a>
</Link>
</>
)}
</>
);
return (
<nav className="navbar mb-2 w-full">
<Link href="/">
<a>
<Image src={Logo} alt="SupaNexTail Logo" />
</a>
</Link>
<div className="hidden lg:flex text-center flex-col lg:flex-row lg:space-x-10 font-body text-sm ml-auto">
{NavMenu}
</div>
<div className="ml-auto lg:hidden">
<div className="dropdown dropdown-end" data-cy="dropdown">
<div tabIndex={0} className="m-1 cursor-pointer">
<Menu />
</div>
<div className="menu dropdown-content mt-3 text-center space-y-3 w-24">{NavMenu}</div>
</div>
</div>
</nav>
);
};
export default Nav;

View File

@@ -3,77 +3,64 @@ import { Dialog, Transition } from '@headlessui/react';
import { Fragment } from 'react'; import { Fragment } from 'react';
const PaymentModal = (props) => { const PaymentModal = (props) => {
function closeModal() { function closeModal() {
props.setPayment(false); props.setPayment(false);
} }
return ( return (
<> <>
<Transition appear show={props.open} as={Fragment}> <Transition appear show={props.open} as={Fragment}>
<Dialog <Dialog
as="div" as="div"
className="fixed inset-0 z-10 overflow-y-auto bg-gray-500 bg-opacity-50" className="fixed inset-0 z-10 overflow-y-auto bg-gray-500 bg-opacity-50"
onClose={closeModal} onClose={closeModal}
> >
<div className="min-h-screen px-4 text-center"> <div className="min-h-screen px-4 text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Dialog.Overlay className="fixed inset-0" /> <Dialog.Overlay className="fixed inset-0" />
</Transition.Child> </Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */} {/* This element is to trick the browser into centering the modal contents. */}
<span <span className="inline-block h-screen align-middle" aria-hidden="true">
className="inline-block h-screen align-middle" &#8203;
aria-hidden="true" </span>
> <Transition.Child
&#8203; as={Fragment}
</span> enter="ease-out duration-300"
<Transition.Child enterFrom="opacity-0 scale-95"
as={Fragment} enterTo="opacity-100 scale-100"
enter="ease-out duration-300" leave="ease-in duration-200"
enterFrom="opacity-0 scale-95" leaveFrom="opacity-100 scale-100"
enterTo="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
leave="ease-in duration-200" >
leaveFrom="opacity-100 scale-100" <div className="inline-block w-full max-w-lg p-8 my-8 overflow-hidden text-left align-middle transition-all transform shadow-xl rounded-2xl bg-base-100 text-base-content border-2 border-accent-focus">
leaveTo="opacity-0 scale-95" <Dialog.Title as="h3" className="text-2xl font-bold leading-6 mb-5 text-center">
> Payment successful 🎉
<div className="inline-block w-full max-w-lg p-8 my-8 overflow-hidden text-left align-middle transition-all transform shadow-xl rounded-2xl bg-base-100 text-base-content border-2 border-accent-focus"> </Dialog.Title>
<Dialog.Title <div className="mt-2">
as="h3" <p>Your payment has been successfully submitted. Thank you for your support!</p>
className="text-2xl font-bold leading-6 mb-5 text-center" </div>
>
Payment successful 🎉
</Dialog.Title>
<div className="mt-2">
<p>
Your payment has been successfully submitted. Thank you for
your support!
</p>
</div>
<div className="mt-4"> <div className="mt-4">
<button <button type="button" className="btn btn-accent flex m-auto" onClick={closeModal}>
type="button" Got it, thanks!
className="btn btn-accent flex m-auto" </button>
onClick={closeModal} </div>
> </div>
Got it, thanks! </Transition.Child>
</button> </div>
</div> </Dialog>
</div> </Transition>
</Transition.Child> </>
</div> );
</Dialog>
</Transition>
</>
);
}; };
export default PaymentModal; export default PaymentModal;

View File

@@ -16,264 +16,237 @@ import router from 'next/router';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
const Pricing = () => { const Pricing = () => {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const [customerId, setCustomerId] = useState(null); const [customerId, setCustomerId] = useState(null);
const [sub, setSub] = useState(false); const [sub, setSub] = useState(false);
const flat = false; // Switch between subscription system or flat prices const flat = false; // Switch between subscription system or flat prices
const portal = () => { const portal = () => {
axios axios
.post('/api/stripe/customer-portal', { .post('/api/stripe/customer-portal', {
customerId, customerId,
}) })
.then((result) => { .then((result) => {
router.push(result.data.url); router.push(result.data.url);
}); });
}; };
useEffect(() => { useEffect(() => {
if (user) { if (user) {
getSub().then((result) => setSub(result)); getSub().then((result) => setSub(result));
supabase supabase
.from('subscriptions') .from('subscriptions')
.select(`customer_id`) .select(`customer_id`)
.eq('id', user.id) .eq('id', user.id)
.single() .single()
.then((result) => { .then((result) => {
setCustomerId(result.data?.customer_id); setCustomerId(result.data?.customer_id);
}); });
} }
}, [user]); }, [user]);
const pricing = { const pricing = {
monthly: { monthly: {
personal: '$5/mo', personal: '$5/mo',
team: '$15/mo', team: '$15/mo',
pro: '$35/mo', pro: '$35/mo',
}, },
yearly: { yearly: {
personal: '$50/yr', personal: '$50/yr',
team: '$150/yr', team: '$150/yr',
pro: '$350/yr', pro: '$350/yr',
}, },
flat: { flat: {
personal: '€49', personal: '€49',
team: '€99', team: '€99',
pro: '€149', pro: '€149',
}, },
}; };
const handleSubmit = async (e, priceId) => { const handleSubmit = async (e, priceId) => {
e.preventDefault(); e.preventDefault();
// Create a Checkout Session. This will redirect the user to the Stripe website for the payment. // Create a Checkout Session. This will redirect the user to the Stripe website for the payment.
axios axios
.post('/api/stripe/create-checkout-session', { .post('/api/stripe/create-checkout-session', {
priceId, priceId,
email: user.email, email: user.email,
customerId, customerId,
userId: user.id, userId: user.id,
tokenId: session.access_token, tokenId: session.access_token,
pay_mode: flat ? 'payment' : 'subscription', pay_mode: flat ? 'payment' : 'subscription',
}) })
.then((result) => router.push(result.data.url)); .then((result) => router.push(result.data.url));
}; };
return ( return (
<div className="w-full mx-auto px-5 py-10 mb-10"> <div className="w-full mx-auto px-5 py-10 mb-10">
<div className="text-center max-w-xl mx-auto"> <div className="text-center max-w-xl mx-auto">
<h1 className="text-3xl sm:text-5xl font-bold font-title mb-5"> <h1 className="text-3xl sm:text-5xl font-bold font-title mb-5">Pricing</h1>
Pricing <h3 className="text-lg font-light leading-8 p-3 mb-5">
</h1> This is an example of a pricing page. You can choose a payment method, monthly or yearly.
<h3 className="text-lg font-light leading-8 p-3 mb-5"> </h3>
This is an example of a pricing page. You can choose a payment method, </div>
monthly or yearly. {!flat && (
</h3> <div className="flex justify-between max-w-xs m-auto mb-3">
</div> <div>
{!flat && ( <p className={`${enabled ? 'text-gray-500' : null}`}>Billed monthly</p>
<div className="flex justify-between max-w-xs m-auto mb-3"> </div>
<div> <div>
<p className={`${enabled ? 'text-gray-500' : null}`}> <Switch
Billed monthly checked={enabled}
</p> onChange={setEnabled}
</div> className={`bg-primary relative inline-flex flex-shrink-0 h-[38px] w-[74px]
<div>
<Switch
checked={enabled}
onChange={setEnabled}
className={`bg-primary relative inline-flex flex-shrink-0 h-[38px] w-[74px]
border-2 border-transparent rounded-full cursor-pointer transition-colors border-2 border-transparent rounded-full cursor-pointer transition-colors
ease-in-out duration-200 focus:outline-none focus-visible:ring-2 ease-in-out duration-200 focus:outline-none focus-visible:ring-2
focus-visible:ring-white focus-visible:ring-opacity-75`} focus-visible:ring-white focus-visible:ring-opacity-75`}
> >
<span className="sr-only">Switch bill</span> <span className="sr-only">Switch bill</span>
<span <span
aria-hidden="true" aria-hidden="true"
className={`${enabled ? 'translate-x-9' : 'translate-x-0'} className={`${enabled ? 'translate-x-9' : 'translate-x-0'}
pointer-events-none inline-block h-[34px] w-[34px] rounded-full bg-white shadow-lg transform ring-0 transition ease-in-out duration-200`} pointer-events-none inline-block h-[34px] w-[34px] rounded-full bg-white shadow-lg transform ring-0 transition ease-in-out duration-200`}
/> />
</Switch> </Switch>
</div> </div>
<div> <div>
<p className={`${!enabled ? 'text-gray-500' : null}`}> <p className={`${!enabled ? 'text-gray-500' : null}`}>Billed annually</p>
Billed annually </div>
</p> </div>
</div> )}
</div> <div className="max-w-4xl mx-auto md:flex space-x-4">
)} <div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col">
<div className="max-w-4xl mx-auto md:flex space-x-4"> <div className="w-full flex-grow">
<div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col"> <h2 className="text-center font-bold uppercase mb-4">Personal</h2>
<div className="w-full flex-grow"> <h3 className="text-center font-bold text-4xl mb-5">
<h2 className="text-center font-bold uppercase mb-4">Personal</h2> {flat
<h3 className="text-center font-bold text-4xl mb-5"> ? pricing.flat.personal
{flat : enabled
? pricing.flat.personal ? pricing.yearly.personal
: enabled : pricing.monthly.personal}
? pricing.yearly.personal </h3>
: pricing.monthly.personal} <ul className="text-sm px-5 mb-8 text-left">
</h3> <li className="leading-tight">
<ul className="text-sm px-5 mb-8 text-left"> <i className="mdi mdi-check-bold text-lg" /> A cool feature
<li className="leading-tight"> </li>
<i className="mdi mdi-check-bold text-lg" /> A cool feature <li className="leading-tight">
</li> <i className="mdi mdi-check-bold text-lg" /> Another feature
<li className="leading-tight"> </li>
<i className="mdi mdi-check-bold text-lg" /> Another feature </ul>
</li> </div>
</ul> <div className="w-full">
</div> <button
<div className="w-full"> className="btn btn-primary w-full"
<button onClick={
className="btn btn-primary w-full" user
onClick={ ? sub
user ? () => {
? sub portal();
? () => { }
portal(); : (e) =>
} handleSubmit(
: (e) => e,
handleSubmit( enabled ? Prices.personal.annually.id : Prices.personal.monthly.id
e, )
enabled : () => {
? Prices.personal.annually.id router.push('/auth');
: Prices.personal.monthly.id }
) }
: () => { >
router.push('/auth'); {user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'}
} </button>
} </div>
> </div>
{user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'} <div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col">
</button> <div className="w-full flex-grow">
</div> <h2 className="text-center font-bold uppercase mb-4">Team</h2>
</div> <h3 className="text-center font-bold text-4xl mb-5">
<div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col"> {flat ? pricing.flat.team : enabled ? pricing.yearly.team : pricing.monthly.team}
<div className="w-full flex-grow"> </h3>
<h2 className="text-center font-bold uppercase mb-4">Team</h2> <ul className="text-sm px-5 mb-8 text-left">
<h3 className="text-center font-bold text-4xl mb-5"> <li className="leading-tight">
{flat <i className="mdi mdi-check-bold text-lg" /> All basic features
? pricing.flat.team </li>
: enabled <li className="leading-tight">
? pricing.yearly.team <i className="mdi mdi-check-bold text-lg" /> Dolor sit amet
: pricing.monthly.team} </li>
</h3> <li className="leading-tight">
<ul className="text-sm px-5 mb-8 text-left"> <i className="mdi mdi-check-bold text-lg" /> Consectetur
<li className="leading-tight"> </li>
<i className="mdi mdi-check-bold text-lg" /> All basic features <li className="leading-tight">
</li> <i className="mdi mdi-check-bold text-lg" /> Adipisicing
<li className="leading-tight"> </li>
<i className="mdi mdi-check-bold text-lg" /> Dolor sit amet <li className="leading-tight">
</li> <i className="mdi mdi-check-bold text-lg" /> Elit repellat
<li className="leading-tight"> </li>
<i className="mdi mdi-check-bold text-lg" /> Consectetur </ul>
</li> </div>
<li className="leading-tight"> <div className="w-full">
<i className="mdi mdi-check-bold text-lg" /> Adipisicing <button
</li> className="btn btn-primary w-full"
<li className="leading-tight"> onClick={
<i className="mdi mdi-check-bold text-lg" /> Elit repellat user
</li> ? sub
</ul> ? () => {
</div> portal();
<div className="w-full"> }
<button : (e) =>
className="btn btn-primary w-full" handleSubmit(e, enabled ? Prices.team.annually.id : Prices.team.monthly.id)
onClick={ : () => {
user router.push('/auth');
? sub }
? () => { }
portal(); >
} {user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'}
: (e) => </button>
handleSubmit( </div>
e, </div>
enabled <div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col">
? Prices.team.annually.id <div className="w-full flex-grow">
: Prices.team.monthly.id <h2 className="text-center font-bold uppercase mb-4">Pro</h2>
) <h3 className="text-center font-bold text-4xl mb-5">
: () => { {flat ? pricing.flat.pro : enabled ? pricing.yearly.pro : pricing.monthly.pro}
router.push('/auth'); </h3>
} <ul className="text-sm px-5 mb-8 text-left">
} <li className="leading-tight">
> <i className="mdi mdi-check-bold text-lg" /> Lorem ipsum
{user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'} </li>
</button> <li className="leading-tight">
</div> <i className="mdi mdi-check-bold text-lg" /> Dolor sit amet
</div> </li>
<div className="w-full md:w-1/3 md:max-w-none px-8 md:px-10 py-8 md:py-10 mb-3 mx-auto md:my-6 rounded-md shadow-lg shadow-gray-600 md:flex md:flex-col"> <li className="leading-tight">
<div className="w-full flex-grow"> <i className="mdi mdi-check-bold text-lg" /> Consectetur
<h2 className="text-center font-bold uppercase mb-4">Pro</h2> </li>
<h3 className="text-center font-bold text-4xl mb-5"> <li className="leading-tight">
{flat <i className="mdi mdi-check-bold text-lg" /> Adipisicing
? pricing.flat.pro </li>
: enabled <li className="leading-tight">
? pricing.yearly.pro <i className="mdi mdi-check-bold text-lg" /> Much more...
: pricing.monthly.pro} </li>
</h3> </ul>
<ul className="text-sm px-5 mb-8 text-left"> </div>
<li className="leading-tight"> <div className="w-full">
<i className="mdi mdi-check-bold text-lg" /> Lorem ipsum <button
</li> className="btn btn-primary w-full"
<li className="leading-tight"> onClick={
<i className="mdi mdi-check-bold text-lg" /> Dolor sit amet user
</li> ? sub
<li className="leading-tight"> ? () => {
<i className="mdi mdi-check-bold text-lg" /> Consectetur portal();
</li> }
<li className="leading-tight"> : (e) =>
<i className="mdi mdi-check-bold text-lg" /> Adipisicing handleSubmit(e, enabled ? Prices.pro.annually.id : Prices.pro.monthly.id)
</li> : () => {
<li className="leading-tight"> router.push('/auth');
<i className="mdi mdi-check-bold text-lg" /> Much more... }
</li> }
</ul> >
</div> {user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'}
<div className="w-full"> </button>
<button </div>
className="btn btn-primary w-full" </div>
onClick={ </div>
user </div>
? sub );
? () => {
portal();
}
: (e) =>
handleSubmit(
e,
enabled
? Prices.pro.annually.id
: Prices.pro.monthly.id
)
: () => {
router.push('/auth');
}
}
>
{user ? (sub ? 'Upgrade' : 'Buy Now') : 'Register'}
</button>
</div>
</div>
</div>
</div>
);
}; };
export default Pricing; export default Pricing;

View File

@@ -1,159 +1,135 @@
const PrivacyPolicy = () => ( const PrivacyPolicy = () => (
<div className="max-w-xl text-left m-auto py-10"> <div className="max-w-xl text-left m-auto py-10">
<h1 className="text-center"> <h1 className="text-center">Privacy Policy for {process.env.NEXT_PUBLIC_TITLE}</h1>
Privacy Policy for {process.env.NEXT_PUBLIC_TITLE}
</h1>
<p> <p>
At {process.env.NEXT_PUBLIC_TITLE}, accessible from At {process.env.NEXT_PUBLIC_TITLE}, accessible from https://www.supanextail.dev, one of our
https://www.supanextail.dev, one of our main priorities is the privacy of main priorities is the privacy of our visitors. This Privacy Policy document contains types of
our visitors. This Privacy Policy document contains types of information information that is collected and recorded by
that is collected and recorded by {process.env.NEXT_PUBLIC_TITLE} and how we use it.
{process.env.NEXT_PUBLIC_TITLE} and how we use it. </p>
</p>
<p> <p>
If you have additional questions or require more information about our If you have additional questions or require more information about our Privacy Policy, do not
Privacy Policy, do not hesitate to contact us. hesitate to contact us.
</p> </p>
<h2>General Data Protection Regulation (GDPR)</h2> <h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p> <p>We are a Data Controller of your information.</p>
<p> <p>
{process.env.NEXT_PUBLIC_TITLE} legal basis for collecting and using the {process.env.NEXT_PUBLIC_TITLE} legal basis for collecting and using the personal information
personal information described in this Privacy Policy depends on the described in this Privacy Policy depends on the Personal Information we collect and the
Personal Information we collect and the specific context in which we specific context in which we collect the information:
collect the information: </p>
</p> <ul>
<ul> <li>{process.env.NEXT_PUBLIC_TITLE} needs to perform a contract with you</li>
<li> <li>You have given {process.env.NEXT_PUBLIC_TITLE} permission to do so</li>
{process.env.NEXT_PUBLIC_TITLE} needs to perform a contract with you <li>
</li> Processing your personal information is in {process.env.NEXT_PUBLIC_TITLE} legitimate
<li> interests
You have given {process.env.NEXT_PUBLIC_TITLE} permission to do so </li>
</li> <li>{process.env.NEXT_PUBLIC_TITLE} needs to comply with the law</li>
<li> </ul>
Processing your personal information is in{' '}
{process.env.NEXT_PUBLIC_TITLE} legitimate interests
</li>
<li>{process.env.NEXT_PUBLIC_TITLE} needs to comply with the law</li>
</ul>
<p> <p>
{process.env.NEXT_PUBLIC_TITLE} will retain your personal information only {process.env.NEXT_PUBLIC_TITLE} will retain your personal information only for as long as is
for as long as is necessary for the purposes set out in this Privacy necessary for the purposes set out in this Privacy Policy. We will retain and use your
Policy. We will retain and use your information to the extent necessary to information to the extent necessary to comply with our legal obligations, resolve disputes,
comply with our legal obligations, resolve disputes, and enforce our and enforce our policies.
policies. </p>
</p>
<p> <p>
If you are a resident of the European Economic Area (EEA), you have If you are a resident of the European Economic Area (EEA), you have certain data protection
certain data protection rights. If you wish to be informed what Personal rights. If you wish to be informed what Personal Information we hold about you and if you want
Information we hold about you and if you want it to be removed from our it to be removed from our systems, please contact us.
systems, please contact us. </p>
</p> <p>In certain circumstances, you have the following data protection rights:</p>
<p> <ul>
In certain circumstances, you have the following data protection rights: <li>The right to access, update or to delete the information we have on you.</li>
</p> <li>The right of rectification.</li>
<ul> <li>The right to object.</li>
<li> <li>The right of restriction.</li>
The right to access, update or to delete the information we have on you. <li>The right to data portability</li>
</li> <li>The right to withdraw consent</li>
<li>The right of rectification.</li> </ul>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
</ul>
<h2>Log Files</h2> <h2>Log Files</h2>
<p> <p>
{process.env.NEXT_PUBLIC_TITLE} follows a standard procedure of using log {process.env.NEXT_PUBLIC_TITLE} follows a standard procedure of using log files. These files
files. These files log visitors when they visit websites. All hosting log visitors when they visit websites. All hosting companies do this and a part of hosting
companies do this and a part of hosting services' analytics. The services' analytics. The information collected by log files include internet protocol (IP)
information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit
addresses, browser type, Internet Service Provider (ISP), date and time pages, and possibly the number of clicks. These are not linked to any information that is
stamp, referring/exit pages, and possibly the number of clicks. These are personally identifiable. The purpose of the information is for analyzing trends, administering
not linked to any information that is personally identifiable. The purpose the site, tracking users' movement on the website, and gathering demographic information.
of the information is for analyzing trends, administering the site, </p>
tracking users' movement on the website, and gathering demographic
information.
</p>
<h2>Privacy Policies</h2> <h2>Privacy Policies</h2>
<p> <p>
You may consult this list to find the Privacy Policy for each of the You may consult this list to find the Privacy Policy for each of the advertising partners of{' '}
advertising partners of {process.env.NEXT_PUBLIC_TITLE}. {process.env.NEXT_PUBLIC_TITLE}.
</p> </p>
<p> <p>
Third-party ad servers or ad networks uses technologies like cookies, Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web
JavaScript, or Web Beacons that are used in their respective Beacons that are used in their respective advertisements and links that appear on{' '}
advertisements and links that appear on {process.env.NEXT_PUBLIC_TITLE}, {process.env.NEXT_PUBLIC_TITLE}, which are sent directly to users' browser. They automatically
which are sent directly to users' browser. They automatically receive your receive your IP address when this occurs. These technologies are used to measure the
IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content
effectiveness of their advertising campaigns and/or to personalize the that you see on websites that you visit.
advertising content that you see on websites that you visit. </p>
</p>
<p> <p>
Note that {process.env.NEXT_PUBLIC_TITLE} has no access to or control over Note that {process.env.NEXT_PUBLIC_TITLE} has no access to or control over these cookies that
these cookies that are used by third-party advertisers. are used by third-party advertisers.
</p> </p>
<h2>Third Party Privacy Policies</h2> <h2>Third Party Privacy Policies</h2>
<p> <p>
{process.env.NEXT_PUBLIC_TITLE}'s Privacy Policy does not apply to other {process.env.NEXT_PUBLIC_TITLE}'s Privacy Policy does not apply to other advertisers or
advertisers or websites. Thus, we are advising you to consult the websites. Thus, we are advising you to consult the respective Privacy Policies of these
respective Privacy Policies of these third-party ad servers for more third-party ad servers for more detailed information. It may include their practices and
detailed information. It may include their practices and instructions instructions about how to opt-out of certain options.{' '}
about how to opt-out of certain options.{' '} </p>
</p>
<p> <p>
You can choose to disable cookies through your individual browser options. You can choose to disable cookies through your individual browser options. To know more
To know more detailed information about cookie management with specific detailed information about cookie management with specific web browsers, it can be found at
web browsers, it can be found at the browsers' respective websites. the browsers' respective websites.
</p> </p>
<h2>Children's Information</h2> <h2>Children's Information</h2>
<p> <p>
Another part of our priority is adding protection for children while using Another part of our priority is adding protection for children while using the internet. We
the internet. We encourage parents and guardians to observe, participate encourage parents and guardians to observe, participate in, and/or monitor and guide their
in, and/or monitor and guide their online activity. online activity.
</p> </p>
<p> <p>
{process.env.NEXT_PUBLIC_TITLE} does not knowingly collect any Personal {process.env.NEXT_PUBLIC_TITLE} does not knowingly collect any Personal Identifiable
Identifiable Information from children under the age of 13. If you think Information from children under the age of 13. If you think that your child provided this kind
that your child provided this kind of information on our website, we of information on our website, we strongly encourage you to contact us immediately and we will
strongly encourage you to contact us immediately and we will do our best do our best efforts to promptly remove such information from our records.
efforts to promptly remove such information from our records. </p>
</p>
<h2>Online Privacy Policy Only</h2> <h2>Online Privacy Policy Only</h2>
<p> <p>
Our Privacy Policy applies only to our online activities and is valid for Our Privacy Policy applies only to our online activities and is valid for visitors to our
visitors to our website with regards to the information that they shared website with regards to the information that they shared and/or collect in{' '}
and/or collect in {process.env.NEXT_PUBLIC_TITLE}. This policy is not {process.env.NEXT_PUBLIC_TITLE}. This policy is not applicable to any information collected
applicable to any information collected offline or via channels other than offline or via channels other than this website.
this website. </p>
</p>
<h2>Consent</h2> <h2>Consent</h2>
<p> <p>By using our website, you hereby consent to our Privacy Policy and agree to its terms.</p>
By using our website, you hereby consent to our Privacy Policy and agree </div>
to its terms.
</p>
</div>
); );
export default PrivacyPolicy; export default PrivacyPolicy;

View File

@@ -12,31 +12,31 @@ import { useAuth } from 'utils/AuthContext';
import SignUpPanel from './UI/SignUpPanel'; import SignUpPanel from './UI/SignUpPanel';
const Container = (props) => { const Container = (props) => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
if (user) if (user)
return ( return (
<div className="w-80 md:w-96 order-first lg:order-last"> <div className="w-80 md:w-96 order-first lg:order-last">
<p>Hello {user.email}! 👋 You are already logged in</p> <p>Hello {user.email}! 👋 You are already logged in</p>
<button className="btn btn-primary" onClick={() => signOut()}> <button className="btn btn-primary" onClick={() => signOut()}>
Sign out Sign out
</button> </button>
</div> </div>
); );
return props.children; return props.children;
}; };
const AuthComponent = () => { const AuthComponent = () => {
const { signUp, signIn, signOut, resetPassword } = useAuth(); const { signUp, signIn, signOut, resetPassword } = useAuth();
return ( return (
<Container supabaseClient={supabase}> <Container supabaseClient={supabase}>
<SignUpPanel <SignUpPanel
signUp={signUp} signUp={signUp}
signIn={signIn} signIn={signIn}
signOut={signOut} signOut={signOut}
resetPassword={resetPassword} resetPassword={resetPassword}
/> />
</Container> </Container>
); );
}; };
export default AuthComponent; export default AuthComponent;

View File

@@ -1,235 +1,201 @@
const Terms = () => ( const Terms = () => (
<div className="max-w-xl text-left m-auto py-10"> <div className="max-w-xl text-left m-auto py-10">
<h1>Terms and Conditions</h1> <h1>Terms and Conditions</h1>
<p> <p>
The following terms and conditions (collectively, these "Terms and The following terms and conditions (collectively, these "Terms and Conditions") apply to your
Conditions") apply to your use of{' '} use of <span className="website_url">https://www.supanextail.dev</span>, including any
<span className="website_url">https://www.supanextail.dev</span>, content, functionality and services offered on or via{' '}
including any content, functionality and services offered on or via{' '} <span className="website_url">https://www.supanextail.dev</span> (the "Website").
<span className="website_url">https://www.supanextail.dev</span> (the </p>
"Website").
</p>
<p> <p>
Please read the Terms and Conditions carefully before you start using{' '} Please read the Terms and Conditions carefully before you start using{' '}
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>, <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>, because by using the
because by using the Website you accept and agree to be bound and abide by Website you accept and agree to be bound and abide by these Terms and Conditions.
these Terms and Conditions. </p>
</p>
<p> <p>
These Terms and Conditions are effective as of{' '} These Terms and Conditions are effective as of <span className="date">06/22/2021</span>. We
<span className="date">06/22/2021</span>. We expressly reserve the right expressly reserve the right to change these Terms and Conditions from time to time without
to change these Terms and Conditions from time to time without notice to notice to you. You acknowledge and agree that it is your responsibility to review this Website
you. You acknowledge and agree that it is your responsibility to review and these Terms and Conditions from time to time and to familiarize yourself with any
this Website and these Terms and Conditions from time to time and to modifications. Your continued use of this Website after such modifications will constitute
familiarize yourself with any modifications. Your continued use of this acknowledgement of the modified Terms and Conditions and agreement to abide and be bound by
Website after such modifications will constitute acknowledgement of the the modified Terms and Conditions.
modified Terms and Conditions and agreement to abide and be bound by the </p>
modified Terms and Conditions.
</p>
<h2>Conduct on Website</h2> <h2>Conduct on Website</h2>
<p> <p>
Your use of the Website is subject to all applicable laws and regulations, Your use of the Website is subject to all applicable laws and regulations, and you are solely
and you are solely responsible for the substance of your communications responsible for the substance of your communications through the Website.
through the Website. </p>
</p>
<p> <p>
By posting information in or otherwise using any communications service, By posting information in or otherwise using any communications service, chat room, message
chat room, message board, newsgroup, software library, or other board, newsgroup, software library, or other interactive service that may be available to you
interactive service that may be available to you on or through this on or through this Website, you agree that you will not upload, share, post, or otherwise
Website, you agree that you will not upload, share, post, or otherwise distribute or facilitate distribution of any content including text, communications,
distribute or facilitate distribution of any content including text, software, images, sounds, data, or other information that:
communications, software, images, sounds, data, or other information </p>
that:
</p>
<ul> <ul>
<li> <li>
Is unlawful, threatening, abusive, harassing, defamatory, libelous, Is unlawful, threatening, abusive, harassing, defamatory, libelous, deceptive, fraudulent,
deceptive, fraudulent, invasive of another's privacy, tortious, contains invasive of another's privacy, tortious, contains explicit or graphic descriptions or
explicit or graphic descriptions or accounts of sexual acts (including accounts of sexual acts (including but not limited to sexual language of a violent or
but not limited to sexual language of a violent or threatening nature threatening nature directed at another individual or group of individuals), or otherwise
directed at another individual or group of individuals), or otherwise violates our rules or policies
violates our rules or policies </li>
</li> <li>
<li> Victimizes, harasses, degrades, or intimidates an individual or group of individuals on the
Victimizes, harasses, degrades, or intimidates an individual or group of basis of religion, gender, sexual orientation, race, ethnicity, age, or disability
individuals on the basis of religion, gender, sexual orientation, race, </li>
ethnicity, age, or disability <li>
</li> Infringes on any patent, trademark, trade secret, copyright, right of publicity, or other
<li> proprietary right of any party
Infringes on any patent, trademark, trade secret, copyright, right of </li>
publicity, or other proprietary right of any party <li>
</li> Constitutes unauthorized or unsolicited advertising, junk or bulk email (also known as
<li> "spamming"), chain letters, any other form of unauthorized solicitation, or any form of
Constitutes unauthorized or unsolicited advertising, junk or bulk email lottery or gambling
(also known as "spamming"), chain letters, any other form of </li>
unauthorized solicitation, or any form of lottery or gambling <li>
</li> Contains software viruses or any other computer code, files, or programs that are designed
<li> or intended to disrupt, damage, or limit the functioning of any software, hardware, or
Contains software viruses or any other computer code, files, or programs telecommunications equipment or to damage or obtain unauthorized access to any data or other
that are designed or intended to disrupt, damage, or limit the information of any third party
functioning of any software, hardware, or telecommunications equipment </li>
or to damage or obtain unauthorized access to any data or other <li>Impersonates any person or entity, including any of our employees or representatives</li>
information of any third party </ul>
</li>
<li>
Impersonates any person or entity, including any of our employees or
representatives
</li>
</ul>
<p> <p>
We neither endorse nor assume any liability for the contents of any We neither endorse nor assume any liability for the contents of any material uploaded or
material uploaded or submitted by third party users of the Website. We submitted by third party users of the Website. We generally do not pre-screen, monitor, or
generally do not pre-screen, monitor, or edit the content posted by users edit the content posted by users of communications services, chat rooms, message boards,
of communications services, chat rooms, message boards, newsgroups, newsgroups, software libraries, or other interactive services that may be available on or
software libraries, or other interactive services that may be available on through this Website.
or through this Website. </p>
</p>
<p> <p>
However, we and our agents have the right at their sole discretion to However, we and our agents have the right at their sole discretion to remove any content that,
remove any content that, in our judgment, does not comply with these Terms in our judgment, does not comply with these Terms of Use and any other rules of user conduct
of Use and any other rules of user conduct for our Website, or is for our Website, or is otherwise harmful, objectionable, or inaccurate. We are not responsible
otherwise harmful, objectionable, or inaccurate. We are not responsible for any failure or delay in removing such content. You hereby consent to such removal and
for any failure or delay in removing such content. You hereby consent to waive any claim against us arising out of such removal of content.
such removal and waive any claim against us arising out of such removal of </p>
content.
</p>
<p> <p>
You agree that we may at any time, and at our sole discretion, terminate You agree that we may at any time, and at our sole discretion, terminate your membership,
your membership, account, or other affiliation with our site without prior account, or other affiliation with our site without prior notice to you for violating any of
notice to you for violating any of the above provisions. the above provisions.
</p> </p>
<p> <p>
In addition, you acknowledge that we will cooperate fully with In addition, you acknowledge that we will cooperate fully with investigations of violations of
investigations of violations of systems or network security at other systems or network security at other sites, including cooperating with law enforcement
sites, including cooperating with law enforcement authorities in authorities in investigating suspected criminal violations.
investigating suspected criminal violations. </p>
</p>
<h2>Intellectual Property</h2> <h2>Intellectual Property</h2>
<p> <p>
By accepting these Terms and Conditions, you acknowledge and agree that By accepting these Terms and Conditions, you acknowledge and agree that all content presented
all content presented to you on this Website is protected by copyrights, to you on this Website is protected by copyrights, trademarks, service marks, patents or other
trademarks, service marks, patents or other proprietary rights and laws, proprietary rights and laws, and is the sole property of{' '}
and is the sole property of{' '} <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>.
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>. </p>
</p>
<p> <p>
You are only permitted to use the content as expressly authorized by us or You are only permitted to use the content as expressly authorized by us or the specific
the specific content provider. Except for a single copy made for personal content provider. Except for a single copy made for personal use only, you may not copy,
use only, you may not copy, reproduce, modify, republish, upload, post, reproduce, modify, republish, upload, post, transmit, or distribute any documents or
transmit, or distribute any documents or information from this Website in information from this Website in any form or by any means without prior written permission
any form or by any means without prior written permission from us or the from us or the specific content provider, and you are solely responsible for obtaining
specific content provider, and you are solely responsible for obtaining permission before reusing any copyrighted material that is available on this Website.
permission before reusing any copyrighted material that is available on </p>
this Website.
</p>
<h2>Third Party Websites</h2> <h2>Third Party Websites</h2>
<p> <p>
This Website may link you to other sites on the Internet or otherwise This Website may link you to other sites on the Internet or otherwise include references to
include references to information, documents, software, materials and/or information, documents, software, materials and/or services provided by other parties. These
services provided by other parties. These websites may contain information websites may contain information or material that some people may find inappropriate or
or material that some people may find inappropriate or offensive. offensive.
</p> </p>
<p> <p>
These other websites and parties are not under our control, and you These other websites and parties are not under our control, and you acknowledge that we are
acknowledge that we are not responsible for the accuracy, copyright not responsible for the accuracy, copyright compliance, legality, decency, or any other aspect
compliance, legality, decency, or any other aspect of the content of such of the content of such sites, nor are we responsible for errors or omissions in any references
sites, nor are we responsible for errors or omissions in any references to to other parties or their products and services. The inclusion of such a link or reference is
other parties or their products and services. The inclusion of such a link provided merely as a convenience and does not imply endorsement of, or association with, the
or reference is provided merely as a convenience and does not imply Website or party by us, or any warranty of any kind, either express or implied.
endorsement of, or association with, the Website or party by us, or any </p>
warranty of any kind, either express or implied.
</p>
<h2> <h2>Disclaimer of Warranties, Limitations of Liability and Indemnification</h2>
Disclaimer of Warranties, Limitations of Liability and Indemnification
</h2>
<p> <p>
Your use of{' '} Your use of <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> is at your
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> is sole risk. The Website is provided "as is" and "as available". We disclaim all warranties of
at your sole risk. The Website is provided "as is" and "as available". We any kind, express or implied, including, without limitation, the warranties of
disclaim all warranties of any kind, express or implied, including, merchantability, fitness for a particular purpose and non-infringement.
without limitation, the warranties of merchantability, fitness for a </p>
particular purpose and non-infringement.
</p>
<p> <p>
We are not liable for damages, direct or consequential, resulting from We are not liable for damages, direct or consequential, resulting from your use of the
your use of the Website, and you agree to defend, indemnify and hold us Website, and you agree to defend, indemnify and hold us harmless from any claims, losses,
harmless from any claims, losses, liability costs and expenses (including liability costs and expenses (including but not limites to attorney's fees) arising from your
but not limites to attorney's fees) arising from your violation of any violation of any third-party's rights. You acknowledge that you have only a limited,
third-party's rights. You acknowledge that you have only a limited, non-exclusive, nontransferable license to use the Website. Because the Website is not error or
non-exclusive, nontransferable license to use the Website. Because the bug free, you agree that you will use it carefully and avoid using it ways which might result
Website is not error or bug free, you agree that you will use it carefully in any loss of your or any third party's property or information.
and avoid using it ways which might result in any loss of your or any </p>
third party's property or information.
</p>
<h2>Term and termination</h2> <h2>Term and termination</h2>
<p> <p>
This Terms and Conditions will become effective in relation to you when This Terms and Conditions will become effective in relation to you when you create a{' '}
you create a{' '} <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> account or when you
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>{' '} start using the <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> and will
account or when you start using the{' '} remain effective until terminated by you or by us.{' '}
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> and </p>
will remain effective until terminated by you or by us.{' '}
</p>
<p> <p>
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>{' '} <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> reserves the right to
reserves the right to terminate this Terms and Conditions or suspend your terminate this Terms and Conditions or suspend your account at any time in case of
account at any time in case of unauthorized, or suspected unauthorized use unauthorized, or suspected unauthorized use of the Website whether in contravention of this
of the Website whether in contravention of this Terms and Conditions or Terms and Conditions or otherwise. If{' '}
otherwise. If{' '} <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> terminates this Terms
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>{' '} and Conditions, or suspends your account for any of the reasons set out in this section,{' '}
terminates this Terms and Conditions, or suspends your account for any of <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> shall have no liability
the reasons set out in this section,{' '} or responsibility to you.
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span>{' '} </p>
shall have no liability or responsibility to you.
</p>
<h2>Assignment</h2> <h2>Assignment</h2>
<p> <p>
<span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> may <span className="website_name">{process.env.NEXT_PUBLIC_TITLE}</span> may assign this Terms
assign this Terms and Conditions or any part of it without restrictions. and Conditions or any part of it without restrictions. You may not assign this Terms and
You may not assign this Terms and Conditions or any part of it to any Conditions or any part of it to any third party.
third party. </p>
</p>
<h2>Governing Law</h2> <h2>Governing Law</h2>
<p> <p>
These Terms and Conditions and any dispute or claim arising out of, or These Terms and Conditions and any dispute or claim arising out of, or related to them, shall
related to them, shall be governed by and construed in accordance with the be governed by and construed in accordance with the internal laws of the{' '}
internal laws of the <span className="country">fr</span> without giving <span className="country">fr</span> without giving effect to any choice or conflict of law
effect to any choice or conflict of law provision or rule. provision or rule.
</p> </p>
<p> <p>
Any legal suit, action or proceeding arising out of, or related to, these Any legal suit, action or proceeding arising out of, or related to, these Terms of Service or
Terms of Service or the Website shall be instituted exclusively in the the Website shall be instituted exclusively in the federal courts of{' '}
federal courts of <span className="country">fr</span>. <span className="country">fr</span>.
</p> </p>
</div> </div>
); );
export default Terms; export default Terms;

View File

@@ -5,22 +5,22 @@ This card is used on the landing page
import Image from 'next/image'; import Image from 'next/image';
const CardLanding = (props) => { const CardLanding = (props) => {
const { image, title, text } = props; const { image, title, text } = props;
return ( return (
<div className="w-80 h-48 p-5 sm:ml-5 mb-5 bg-base-100 flex"> <div className="w-80 h-48 p-5 sm:ml-5 mb-5 bg-base-100 flex">
<div> <div>
<div className="rounded-full w-12 h-12 border flex bg-neutral-content"> <div className="rounded-full w-12 h-12 border flex bg-neutral-content">
<div className="m-auto flex"> <div className="m-auto flex">
<Image src={image} width={24} height={24} /> <Image src={image} width={24} height={24} />
</div> </div>
</div> </div>
</div> </div>
<div className="ml-8"> <div className="ml-8">
<p className="font-semibold font-title text-lg">{title}</p> <p className="font-semibold font-title text-lg">{title}</p>
<p className="mt-3">{text}</p> <p className="mt-3">{text}</p>
</div> </div>
</div> </div>
); );
}; };
export default CardLanding; export default CardLanding;

View File

@@ -5,12 +5,12 @@ This card is used on the landing page
import { FiStar } from 'react-icons/fi'; import { FiStar } from 'react-icons/fi';
const KeyFeature = (props) => ( const KeyFeature = (props) => (
<div className="shadow-sm p-5 mb-5 bg-base-100 flex italic"> <div className="shadow-sm p-5 mb-5 bg-base-100 flex italic">
<div className="p-2 bg-accent-focus w-12 h-12 text-white rounded-sm my-auto flex"> <div className="p-2 bg-accent-focus w-12 h-12 text-white rounded-sm my-auto flex">
<FiStar className="text-2xl m-auto" /> <FiStar className="text-2xl m-auto" />
</div> </div>
<div className="m-auto ml-3">{props.children}</div> <div className="m-auto ml-3">{props.children}</div>
</div> </div>
); );
export default KeyFeature; export default KeyFeature;

View File

@@ -4,170 +4,163 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
const Login = (props) => { const Login = (props) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [forgot, setForgot] = useState(false); const [forgot, setForgot] = useState(false);
const resetPassword = () => { const resetPassword = () => {
props.resetPassword(email).then((result) => { props.resetPassword(email).then((result) => {
if (result.error) { if (result.error) {
toast.error(result.error.message); toast.error(result.error.message);
} else toast.success('Check your email to reset your password!'); } else toast.success('Check your email to reset your password!');
}); });
}; };
const login = (e) => { const login = (e) => {
e.preventDefault(); e.preventDefault();
// Handle the login. Go to the homepage if success or display an error. // Handle the login. Go to the homepage if success or display an error.
props props
.signIn({ .signIn({
email, email,
password, password,
}) })
.then((result) => { .then((result) => {
if (result.data) { if (result.data) {
router.push('/'); router.push('/');
} }
if (result.error) { if (result.error) {
toast.error(result.error.message); toast.error(result.error.message);
} }
}); });
}; };
return ( return (
<div className="p-10 bg-base-100 md:flex-1 rounded-md text-base-content shadow-md max-w-sm font-body"> <div className="p-10 bg-base-100 md:flex-1 rounded-md text-base-content shadow-md max-w-sm font-body">
{!forgot && ( {!forgot && (
<> <>
<h3 className="my-4 text-2xl font-semibold font-title"> <h3 className="my-4 text-2xl font-semibold font-title">Account Login</h3>
Account Login <form action="#" className="flex flex-col space-y-5">
</h3> <div className="flex flex-col space-y-1">
<form action="#" className="flex flex-col space-y-5"> <label htmlFor="email" className="text-sm">
<div className="flex flex-col space-y-1"> Email address
<label htmlFor="email" className="text-sm"> </label>
Email address <input
</label> type="email"
<input id="email"
type="email" autoFocus
id="email" className="input input-primary input-bordered input-sm"
autoFocus value={email}
className="input input-primary input-bordered input-sm" onChange={(event) => {
value={email} setEmail(event.target.value);
onChange={(event) => { }}
setEmail(event.target.value); />
}} </div>
/> <div className="flex flex-col space-y-1">
</div> <div className="flex items-center justify-between">
<div className="flex flex-col space-y-1"> <label htmlFor="password" className="text-sm">
<div className="flex items-center justify-between"> Password
<label htmlFor="password" className="text-sm"> </label>
Password <button
</label> onClick={() => {
<button setForgot(true);
onClick={() => { }}
setForgot(true); className="text-sm text-blue-600 hover:underline focus:text-blue-800"
}} >
className="text-sm text-blue-600 hover:underline focus:text-blue-800" Forgot Password?
> </button>
Forgot Password? </div>
</button> <input
</div> type="password"
<input id="password"
type="password" className="input input-primary input-bordered input-sm"
id="password" value={password}
className="input input-primary input-bordered input-sm" onChange={(event) => {
value={password} setPassword(event.target.value);
onChange={(event) => { }}
setPassword(event.target.value); />
}} </div>
/>
</div>
<div> <div>
<button <button
className="btn btn-primary w-full" className="btn btn-primary w-full"
onClick={(event) => { onClick={(event) => {
login(event); login(event);
}} }}
> >
Log in Log in
</button> </button>
</div> </div>
<div className="flex flex-col space-y-5"> <div className="flex flex-col space-y-5">
<span className="flex items-center justify-center space-x-2"> <span className="flex items-center justify-center space-x-2">
<span className="h-px bg-gray-400 w-14" /> <span className="h-px bg-gray-400 w-14" />
<span className="font-normal text-gray-500">or login with</span> <span className="font-normal text-gray-500">or login with</span>
<span className="h-px bg-gray-400 w-14" /> <span className="h-px bg-gray-400 w-14" />
</span> </span>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<button <button
href="#" href="#"
className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border border-base-200 rounded-md group hover:bg-base-300 focus:outline-none " className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border border-base-200 rounded-md group hover:bg-base-300 focus:outline-none "
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
props.signIn({ provider: 'google' }); props.signIn({ provider: 'google' });
}} }}
> >
<div className="text-base-content"> <div className="text-base-content">
<IoLogoGoogle /> <IoLogoGoogle />
</div> </div>
<span className="text-sm font-medium text-base-content"> <span className="text-sm font-medium text-base-content">Gmail</span>
Gmail </button>
</span> </div>
</button> </div>
</div> </form>
</div> </>
</form> )}
</> {forgot && (
)} <>
{forgot && ( <h3 className="my-4 text-2xl font-semibold">Password recovery</h3>
<> <form action="#" className="flex flex-col space-y-5">
<h3 className="my-4 text-2xl font-semibold">Password recovery</h3> <div className="flex flex-col space-y-1">
<form action="#" className="flex flex-col space-y-5"> <label htmlFor="email" className="text-sm font-semibold text-gray-500">
<div className="flex flex-col space-y-1"> Email address
<label </label>
htmlFor="email" <input
className="text-sm font-semibold text-gray-500" type="email"
> id="email"
Email address autoFocus
</label> className="input input-primary input-bordered input-sm"
<input value={email}
type="email" onChange={(event) => {
id="email" setEmail(event.target.value);
autoFocus }}
className="input input-primary input-bordered input-sm" />
value={email} </div>
onChange={(event) => {
setEmail(event.target.value);
}}
/>
</div>
<div> <div>
<button <button
className="btn btn-primary w-full btn-sm" className="btn btn-primary w-full btn-sm"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
resetPassword(); resetPassword();
}} }}
> >
Recover my password Recover my password
</button> </button>
</div> </div>
<hr /> <hr />
<button <button
onClick={() => { onClick={() => {
setForgot(false); setForgot(false);
}} }}
className="text-sm text-blue-600 hover:underline focus:text-blue-800" className="text-sm text-blue-600 hover:underline focus:text-blue-800"
> >
Go back to sign in Go back to sign in
</button> </button>
</form> </form>
</> </>
)} )}
</div> </div>
); );
}; };
export default Login; export default Login;

View File

@@ -4,113 +4,107 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
const SignUpPanel = (props) => { const SignUpPanel = (props) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [forgot, setForgot] = useState(false); const [forgot, setForgot] = useState(false);
const resetPassword = () => { const resetPassword = () => {
props.resetPassword(email).then((result) => { props.resetPassword(email).then((result) => {
if (result.error) { if (result.error) {
toast.error(result.error.message); toast.error(result.error.message);
} else toast.success('Check your email to reset your password!'); } else toast.success('Check your email to reset your password!');
}); });
}; };
const signup = (e) => { const signup = (e) => {
e.preventDefault(); e.preventDefault();
// Handle the login. Go to the homepage if success or display an error. // Handle the login. Go to the homepage if success or display an error.
props props
.signUp({ .signUp({
email, email,
password, password,
}) })
.then((result) => { .then((result) => {
if (result.data) { if (result.data) {
router.push('/'); router.push('/');
} }
if (result.error) { if (result.error) {
toast.error(result.error.message); toast.error(result.error.message);
} }
}); });
}; };
return ( return (
<div className="p-10 bg-base-100 md:flex-1 rounded-md text-base-content shadow-md max-w-sm font-body"> <div className="p-10 bg-base-100 md:flex-1 rounded-md text-base-content shadow-md max-w-sm font-body">
{!forgot && ( {!forgot && (
<> <>
<h3 className="my-4 text-2xl font-semibold font-title"> <h3 className="my-4 text-2xl font-semibold font-title">Account Sign Up</h3>
Account Sign Up <form action="#" className="flex flex-col space-y-5">
</h3> <div className="flex flex-col space-y-1">
<form action="#" className="flex flex-col space-y-5"> <label htmlFor="email" className="text-sm">
<div className="flex flex-col space-y-1"> Email address
<label htmlFor="email" className="text-sm"> </label>
Email address <input
</label> type="email"
<input id="email"
type="email" autoFocus
id="email" className="input input-primary input-bordered input-sm"
autoFocus value={email}
className="input input-primary input-bordered input-sm" onChange={(event) => {
value={email} setEmail(event.target.value);
onChange={(event) => { }}
setEmail(event.target.value); />
}} </div>
/> <div className="flex flex-col space-y-1">
</div> <input
<div className="flex flex-col space-y-1"> type="password"
<input id="password"
type="password" className="input input-primary input-bordered input-sm"
id="password" value={password}
className="input input-primary input-bordered input-sm" onChange={(event) => {
value={password} setPassword(event.target.value);
onChange={(event) => { }}
setPassword(event.target.value); />
}} </div>
/>
</div>
<div> <div>
<button <button
className="btn btn-primary w-full" className="btn btn-primary w-full"
onClick={(event) => { onClick={(event) => {
signup(event); signup(event);
}} }}
> >
Sign Up Sign Up
</button> </button>
</div> </div>
<div className="flex flex-col space-y-5"> <div className="flex flex-col space-y-5">
<span className="flex items-center justify-center space-x-2"> <span className="flex items-center justify-center space-x-2">
<span className="h-px bg-gray-400 w-14" /> <span className="h-px bg-gray-400 w-14" />
<span className="font-normal text-gray-500"> <span className="font-normal text-gray-500">or sign up with</span>
or sign up with <span className="h-px bg-gray-400 w-14" />
</span> </span>
<span className="h-px bg-gray-400 w-14" /> <div className="flex flex-col space-y-4">
</span> <button
<div className="flex flex-col space-y-4"> href="#"
<button className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border border-base-200 rounded-md group hover:bg-base-300 focus:outline-none "
href="#" onClick={(event) => {
className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border border-base-200 rounded-md group hover:bg-base-300 focus:outline-none " event.preventDefault();
onClick={(event) => { props.signIn({ provider: 'google' });
event.preventDefault(); }}
props.signIn({ provider: 'google' }); >
}} <div className="text-base-content">
> <IoLogoGoogle />
<div className="text-base-content"> </div>
<IoLogoGoogle /> <span className="text-sm font-medium text-base-content">Gmail</span>
</div> </button>
<span className="text-sm font-medium text-base-content"> </div>
Gmail </div>
</span> </form>
</button> </>
</div> )}
</div> </div>
</form> );
</>
)}
</div>
);
}; };
export default SignUpPanel; export default SignUpPanel;

View File

@@ -7,28 +7,28 @@ import { HiOutlineMoon, HiOutlineSun } from 'react-icons/hi';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const theme = { const theme = {
primary: 'supaTheme', primary: 'supaTheme',
secondary: 'dark', secondary: 'dark',
}; };
const ThemeToggle = () => { const ThemeToggle = () => {
const [activeTheme, setActiveTheme] = useState(document.body.dataset.theme); const [activeTheme, setActiveTheme] = useState(document.body.dataset.theme);
const inactiveTheme = activeTheme === 'supaTheme' ? 'dark' : 'supaTheme'; const inactiveTheme = activeTheme === 'supaTheme' ? 'dark' : 'supaTheme';
useEffect(() => { useEffect(() => {
document.body.dataset.theme = activeTheme; document.body.dataset.theme = activeTheme;
window.localStorage.setItem('theme', activeTheme); window.localStorage.setItem('theme', activeTheme);
}, [activeTheme]); }, [activeTheme]);
return ( return (
<button className="flex ml-3" onClick={() => setActiveTheme(inactiveTheme)}> <button className="flex ml-3" onClick={() => setActiveTheme(inactiveTheme)}>
{activeTheme === theme.secondary ? ( {activeTheme === theme.secondary ? (
<HiOutlineSun className="m-auto text-xl hover:text-accent" /> <HiOutlineSun className="m-auto text-xl hover:text-accent" />
) : ( ) : (
<HiOutlineMoon className="m-auto text-xl hover:text-accent" /> <HiOutlineMoon className="m-auto text-xl hover:text-accent" />
)} )}
</button> </button>
); );
}; };
export default ThemeToggle; export default ThemeToggle;

View File

@@ -5,45 +5,45 @@ You can see that it will do this twice, with 2 different resolution, to test the
*/ */
describe('Basic Test', () => { describe('Basic Test', () => {
context('Desktop resolution', () => { context('Desktop resolution', () => {
it('Visits the homepage and nav links (Desktop)', () => { it('Visits the homepage and nav links (Desktop)', () => {
cy.viewport(1280, 720); cy.viewport(1280, 720);
cy.visit(''); cy.visit('');
cy.get('nav').contains('Pricing').click(); cy.get('nav').contains('Pricing').click();
cy.url().should('include', '/pricing'); cy.url().should('include', '/pricing');
cy.get('nav').contains('Contact').click(); cy.get('nav').contains('Contact').click();
cy.url().should('include', '/contact'); cy.url().should('include', '/contact');
cy.get('nav').contains('Login').click(); cy.get('nav').contains('Login').click();
cy.url().should('include', '/login'); cy.url().should('include', '/login');
cy.get('nav').contains('Sign Up').click(); cy.get('nav').contains('Sign Up').click();
cy.url().should('include', '/signup'); cy.url().should('include', '/signup');
}); });
}); });
context('Mobile resolution', () => { context('Mobile resolution', () => {
it('Visits the homepage and nav links (Mobile)', () => { it('Visits the homepage and nav links (Mobile)', () => {
cy.viewport(680, 720); cy.viewport(680, 720);
cy.visit(''); cy.visit('');
cy.get('[data-cy=dropdown]').click(); cy.get('[data-cy=dropdown]').click();
cy.get('[data-cy=dropdown]').contains('Pricing').click(); cy.get('[data-cy=dropdown]').contains('Pricing').click();
cy.url().should('include', '/pricing'); cy.url().should('include', '/pricing');
cy.get('[data-cy=dropdown]').click(); cy.get('[data-cy=dropdown]').click();
cy.get('[data-cy=dropdown]').contains('Login').click(); cy.get('[data-cy=dropdown]').contains('Login').click();
cy.url().should('include', '/login'); cy.url().should('include', '/login');
cy.get('[data-cy=dropdown]').click(); cy.get('[data-cy=dropdown]').click();
cy.get('[data-cy=dropdown]').contains('Sign Up').click(); cy.get('[data-cy=dropdown]').contains('Sign Up').click();
cy.url().should('include', '/signup'); cy.url().should('include', '/signup');
cy.get('[data-cy=dropdown]').click(); cy.get('[data-cy=dropdown]').click();
cy.get('[data-cy=dropdown]').contains('Contact').click(); cy.get('[data-cy=dropdown]').contains('Contact').click();
cy.url().should('include', '/contact'); cy.url().should('include', '/contact');
}); });
}); });
}); });

View File

@@ -17,6 +17,6 @@
*/ */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
module.exports = (on, config) => { module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits // `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config // `config` is the resolved Cypress config
}; };

View File

@@ -1,5 +0,0 @@
{
"compilerOptions": {
"baseUrl": "."
}
}

3
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />

8294
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,10 @@
"@stripe/stripe-js": "^1.15.1", "@stripe/stripe-js": "^1.15.1",
"@supabase/grid": "1.15.0", "@supabase/grid": "1.15.0",
"@supabase/supabase-js": "^1.21.0", "@supabase/supabase-js": "^1.21.0",
"@types/node": "^16.4.13",
"@types/react": "^17.0.16",
"@types/react-dom": "^17.0.9",
"@types/stripe": "^8.0.417",
"axios": "^0.21.1", "axios": "^0.21.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"daisyui": "^1.11.0", "daisyui": "^1.11.0",
@@ -28,10 +32,14 @@
"react-feather": "^2.0.9", "react-feather": "^2.0.9",
"react-icons": "^4.2.0", "react-icons": "^4.2.0",
"react-toastify": "^7.0.4", "react-toastify": "^7.0.4",
"stripe": "^8.168.0" "stripe": "^8.168.0",
"typescript": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^11.0.1", "@next/eslint-plugin-next": "^11.0.1",
"@types/cors": "^2.8.12",
"@types/express-rate-limit": "^5.1.3",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"autoprefixer": "^10.3.1", "autoprefixer": "^10.3.1",
"cypress": "^8.2.0", "cypress": "^8.2.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",

View File

@@ -9,25 +9,25 @@ setup more elements, visit their Github page https://github.com/garmeeh/next-seo
*/ */
function MyApp({ Component, pageProps }) { function MyApp({ Component, pageProps }) {
return ( return (
<> <>
<AuthProvider> <AuthProvider>
<DefaultSeo <DefaultSeo
openGraph={{ openGraph={{
type: 'website', type: 'website',
locale: 'en_IE', locale: 'en_IE',
url: '', url: '',
site_name: 'Supanextail', site_name: 'Supanextail',
}} }}
twitter={{ twitter={{
handle: '@michael_webdev', handle: '@michael_webdev',
site: '@michael_webdev', site: '@michael_webdev',
}} }}
/> />
<Component {...pageProps} /> <Component {...pageProps} />
</AuthProvider> </AuthProvider>
</> </>
); );
} }
export default MyApp; export default MyApp;

View File

@@ -1,14 +1,14 @@
import Document, { Head, Html, Main, NextScript } from 'next/document'; import Document, { Head, Html, Main, NextScript } from 'next/document';
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx) { static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps }; return { ...initialProps };
} }
render() { render() {
// This will set the initial theme, saved in localstorage // This will set the initial theme, saved in localstorage
const setInitialTheme = ` const setInitialTheme = `
function getUserPreference() { function getUserPreference() {
if(window.localStorage.getItem('theme')) { if(window.localStorage.getItem('theme')) {
return window.localStorage.getItem('theme') return window.localStorage.getItem('theme')
@@ -19,17 +19,17 @@ class MyDocument extends Document {
} }
document.body.dataset.theme = getUserPreference(); document.body.dataset.theme = getUserPreference();
`; `;
return ( return (
<Html> <Html>
<Head /> <Head />
<body> <body>
<script dangerouslySetInnerHTML={{ __html: setInitialTheme }} /> <script dangerouslySetInnerHTML={{ __html: setInitialTheme }} />
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>
</Html> </Html>
); );
} }
} }
export default MyDocument; export default MyDocument;

View File

@@ -6,5 +6,5 @@
import { supabase } from 'utils/supabaseClient'; import { supabase } from 'utils/supabaseClient';
export default function handler(req, res) { export default function handler(req, res) {
supabase.auth.api.setAuthCookie(req, res); supabase.auth.api.setAuthCookie(req, res);
} }

View File

@@ -2,12 +2,12 @@ import { supabase } from 'utils/supabaseClient';
// Example of how to verify and get user data server-side. // Example of how to verify and get user data server-side.
const getUser = async (req, res) => { const getUser = async (req, res) => {
const { token } = req.headers; const { token } = req.headers;
const { data: user, error } = await supabase.auth.api.getUser(token); const { data: user, error } = await supabase.auth.api.getUser(token);
if (error) return res.status(401).json({ error: error.message }); if (error) return res.status(401).json({ error: error.message });
return res.status(200).json(user); return res.status(200).json(user);
}; };
export default getUser; export default getUser;

View File

@@ -1,58 +0,0 @@
import Cors from 'cors';
import axios from 'axios';
import initMiddleware from 'utils/init-middleware';
const rateLimit = require('express-rate-limit');
export const config = {
api: {
externalResolver: true,
},
};
const cors = initMiddleware(
Cors({
methods: ['PUT'],
})
);
const limiter = initMiddleware(
rateLimit({
windowMs: 30000, // 30sec
max: 2, // Max 2 request per 30 sec
})
);
export default async function handler(req, res) {
await cors(req, res);
await limiter(req, res);
if (req.method === 'PUT') {
axios
.put(
'https://api.sendgrid.com/v3/marketing/contacts',
{
contacts: [{ email: `${req.body.mail}` }],
list_ids: [process.env.SENDGRID_MAILING_ID],
},
{
headers: {
'content-type': 'application/json',
Authorization: `Bearer ${process.env.SENDGRID_SECRET}`,
},
}
)
.then((result) => {
console.log(result);
res.status(200).send({
message:
'Your email has been succesfully added to the mailing list. Welcome 👋',
});
})
.catch((err) => {
res.status(500).send({
message:
'Oups, there was a problem with your subscription, please try again or contact us',
});
});
}
}

56
pages/api/mailingList.ts Normal file
View File

@@ -0,0 +1,56 @@
import Cors from 'cors';
import axios from 'axios';
import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit';
export const config = {
api: {
externalResolver: true,
},
};
const cors = initMiddleware(
Cors({
methods: ['PUT'],
})
);
const limiter = initMiddleware(
rateLimit({
windowMs: 30000, // 30sec
max: 2, // Max 2 request per 30 sec
})
);
export default async function handler(req: any, res: any) {
await cors(req, res);
await limiter(req, res);
if (req.method === 'PUT') {
axios
.put(
'https://api.sendgrid.com/v3/marketing/contacts',
{
contacts: [{ email: `${req.body.mail}` }],
list_ids: [process.env.SENDGRID_MAILING_ID],
},
{
headers: {
'content-type': 'application/json',
Authorization: `Bearer ${process.env.SENDGRID_SECRET}`,
},
}
)
.then((result) => {
console.log(result);
res.status(200).send({
message: 'Your email has been succesfully added to the mailing list. Welcome 👋',
});
})
.catch((err) => {
res.status(500).send({
message:
'Oups, there was a problem with your subscription, please try again or contact us',
});
});
}
}

View File

@@ -2,31 +2,30 @@
This is a simple contact form for SupaNexTail This is a simple contact form for SupaNexTail
Using Sendgrid. Using Sendgrid.
*/ */
const sgMail = require('@sendgrid/mail');
import sgMail from '@sendgrid/mail';
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method === 'POST') { if (req.method === 'POST') {
sgMail.setApiKey(process.env.SENDGRID_SECRET); sgMail.setApiKey(process.env.SENDGRID_SECRET);
const msg = { const msg = {
to: process.env.SENDGRID_MAILTO, // Change to your recipient to: process.env.SENDGRID_MAILTO, // Change to your recipient
from: process.env.SENDGRID_MAILFROM, // Change to your verified sender from: process.env.SENDGRID_MAILFROM, // Change to your verified sender
subject: `[${process.env.NEXT_PUBLIC_TITLE}] New message from ${req.body.name}`, subject: `[${process.env.NEXT_PUBLIC_TITLE}] New message from ${req.body.name}`,
text: req.body.message, text: req.body.message,
reply_to: req.body.email, reply_to: req.body.email,
}; };
sgMail sgMail
.send(msg) .send(msg)
.then(() => { .then(() => {
res res.status(200).send({ message: 'Your email has been sent', success: true });
.status(200) })
.send({ message: 'Your email has been sent', success: true }); .catch((error) => {
}) console.error(error);
.catch((error) => { res.status(500).send({
console.error(error); message: 'There was an issue with your email... please retry',
res.status(500).send({ err,
message: 'There was an issue with your email... please retry', });
err, });
}); }
});
}
} }

View File

@@ -1,80 +1,87 @@
import * as Stripe from 'stripe';
import Cors from 'cors'; import Cors from 'cors';
import initMiddleware from 'utils/init-middleware'; import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit';
const rateLimit = require('express-rate-limit');
const cors = initMiddleware( const cors = initMiddleware(
Cors({ Cors({
methods: ['POST'], methods: ['POST'],
}) })
); );
const limiter = initMiddleware( const limiter = initMiddleware(
rateLimit({ rateLimit({
windowMs: 30000, // 30sec windowMs: 30000, // 30sec
max: 4, // Max 4 request per 30 sec max: 4, // Max 4 request per 30 sec
}) })
); );
// Set your secret key. Remember to switch to your live secret key in production. // Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys // See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')(process.env.STRIPE_SECRET); const stripe = new Stripe(process.env.STRIPE_SECRET);
export default async function handler(req, res) { export default async function handler(req, res) {
await cors(req, res); await cors(req, res);
await limiter(req, res); await limiter(req, res);
if (req.method === 'POST') { if (req.method === 'POST') {
const { priceId } = req.body; const { priceId } = req.body;
// See https://stripe.com/docs/api/checkout/sessions/create // See https://stripe.com/docs/api/checkout/sessions/create
// for additional parameters to pass. // for additional parameters to pass.
try { try {
const session = req.body.customerId const session = req.body.customerId
? await stripe.checkout.sessions.create({ ? await stripe.checkout.sessions.create({
mode: req.body.pay_mode, mode: req.body.pay_mode,
payment_method_types: ['card'], payment_method_types: ['card'],
client_reference_id: req.body.userId, client_reference_id: req.body.userId,
metadata: { token: req.body.tokenId, priceId: req.body.priceId }, metadata: {
customer: req.body.customerId, token: req.body.tokenId,
line_items: [ priceId: req.body.priceId,
{ },
price: priceId, customer: req.body.customerId,
// For metered billing, do not pass quantity line_items: [
quantity: 1, {
}, price: priceId,
], // For metered billing, do not pass quantity
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! quantity: 1,
// the actual Session ID is returned in the query parameter when your customer },
// is redirected to the success page. ],
success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
cancel_url: `${req.headers.origin}/pricing`, // the actual Session ID is returned in the query parameter when your customer
}) // is redirected to the success page.
: await stripe.checkout.sessions.create({ success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
mode: 'subscription', cancel_url: `${req.headers.origin}/pricing`,
payment_method_types: ['card'], })
customer_email: req.body.email, : await stripe.checkout.sessions.create({
client_reference_id: req.body.userId, mode: 'subscription',
metadata: { token: req.body.tokenId, priceId: req.body.priceId }, payment_method_types: ['card'],
line_items: [ customer_email: req.body.email,
{ client_reference_id: req.body.userId,
price: priceId, metadata: {
// For metered billing, do not pass quantity token: req.body.tokenId,
quantity: 1, priceId: req.body.priceId,
}, },
], line_items: [
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! {
// the actual Session ID is returned in the query parameter when your customer price: priceId,
// is redirected to the success page. // For metered billing, do not pass quantity
success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, quantity: 1,
cancel_url: `${req.headers.origin}/pricing`, },
}); ],
res.status(200).send({ url: session.url }); // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
} catch (e) { // the actual Session ID is returned in the query parameter when your customer
res.status(400); // is redirected to the success page.
return res.send({ success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
error: { cancel_url: `${req.headers.origin}/pricing`,
message: e.message, });
}, res.status(200).send({ url: session.url });
}); } catch (e) {
} res.status(400);
} return res.send({
error: {
message: e.message,
},
});
}
}
} }

View File

@@ -1,37 +1,38 @@
/* Dont forget to create your customer portal on Stripe /* Dont forget to create your customer portal on Stripe
https://dashboard.stripe.com/test/settings/billing/portal */ https://dashboard.stripe.com/test/settings/billing/portal */
import * as Stripe from 'stripe';
import Cors from 'cors'; import Cors from 'cors';
import initMiddleware from 'utils/init-middleware'; import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit';
const rateLimit = require('express-rate-limit');
const cors = initMiddleware( const cors = initMiddleware(
Cors({ Cors({
methods: ['POST', 'PUT'], methods: ['POST', 'PUT'],
}) })
); );
const limiter = initMiddleware( const limiter = initMiddleware(
rateLimit({ rateLimit({
windowMs: 30000, // 30sec windowMs: 30000, // 30sec
max: 150, // Max 4 request per 30 sec max: 150, // Max 4 request per 30 sec
}) })
); );
// Set your secret key. Remember to switch to your live secret key in production. // Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys // See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')(process.env.STRIPE_SECRET); const stripe = new Stripe(process.env.STRIPE_SECRET);
export default async function handler(req, res) { export default async function handler(req, res) {
await cors(req, res); await cors(req, res);
await limiter(req, res); await limiter(req, res);
if (req.method === 'POST') { if (req.method === 'POST') {
const returnUrl = `${req.headers.origin}/dashboard`; // Stripe will return to the dashboard, you can change it const returnUrl = `${req.headers.origin}/dashboard`; // Stripe will return to the dashboard, you can change it
const portalsession = await stripe.billingPortal.sessions.create({ const portalsession = await stripe.billingPortal.sessions.create({
customer: req.body.customerId, customer: req.body.customerId,
return_url: returnUrl, return_url: returnUrl,
}); });
res.status(200).send({ url: portalsession.url }); res.status(200).send({ url: portalsession.url });
} }
} }

View File

@@ -6,142 +6,139 @@ If you want to test it locally, you'll need the stripe CLI and use this command
stripe listen --forward-to localhost:3000/api/stripe/stripe-webhook stripe listen --forward-to localhost:3000/api/stripe/stripe-webhook
*/ */
import * as Stripe from 'stripe';
import Cors from 'cors'; import Cors from 'cors';
import { buffer } from 'micro'; import { buffer } from 'micro';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import initMiddleware from 'utils/init-middleware'; import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit';
const rateLimit = require('express-rate-limit');
export const config = { export const config = {
api: { api: {
bodyParser: false, bodyParser: false,
}, },
}; };
// Initialize the cors middleware -> Allow the browser extension to create lists // Initialize the cors middleware -> Allow the browser extension to create lists
const cors = initMiddleware( const cors = initMiddleware(
Cors({ Cors({
methods: ['POST', 'HEAD'], methods: ['POST', 'HEAD'],
}) })
); );
// Init Supabase Admin // Init Supabase Admin
const supabase = createClient( const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_ADMIN_KEY);
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_ADMIN_KEY
);
// Rate limiter : The user can only create one list every 20 seconds (avoid spam) // Rate limiter : The user can only create one list every 20 seconds (avoid spam)
const limiter = initMiddleware( const limiter = initMiddleware(
rateLimit({ rateLimit({
windowMs: 30000, // 30sec windowMs: 30000, // 30sec
max: 150, // Max 150 request per 30 sec max: 150, // Max 150 request per 30 sec
}) })
); );
// Set your secret key. Remember to switch to your live secret key in production. // Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://dashboard.stripe.com/apikeys // See your keys here: https://dashboard.stripe.com/apikeys
const stripe = require('stripe')(process.env.STRIPE_SECRET);
const stripe = new Stripe(process.env.STRIPE_SECRET);
export default async function handler(req, res) { export default async function handler(req, res) {
await cors(req, res); await cors(req, res);
await limiter(req, res); await limiter(req, res);
stripe.setMaxNetworkRetries(2); stripe.setMaxNetworkRetries(2);
if (req.method === 'POST') { if (req.method === 'POST') {
// Retrieve the event by verifying the signature using the raw body and secret. // Retrieve the event by verifying the signature using the raw body and secret.
let event; let event;
const buf = await buffer(req); const buf = await buffer(req);
try { try {
event = stripe.webhooks.constructEvent( event = stripe.webhooks.constructEvent(
buf, buf,
req.headers['stripe-signature'], req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK process.env.STRIPE_WEBHOOK
); );
} catch (err) { } catch (err) {
console.log(err); console.log(err);
console.log(`⚠️ Webhook signature verification failed.`); console.log(`⚠️ Webhook signature verification failed.`);
console.log( console.log(`⚠️ Check the env file and enter the correct webhook secret.`);
`⚠️ Check the env file and enter the correct webhook secret.` return res.send(400);
); }
return res.send(400); // Extract the object from the event.
} const dataObject = event.data.object;
// Extract the object from the event.
const dataObject = event.data.object;
// Handle the event // Handle the event
// Review important events for Billing webhooks // Review important events for Billing webhooks
// https://stripe.com/docs/billing/webhooks // https://stripe.com/docs/billing/webhooks
// Remove comment to see the various objects sent for this sample // Remove comment to see the various objects sent for this sample
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': case 'checkout.session.completed':
const { data: subscriptions, error } = await supabase const { data: subscriptions, error } = await supabase
.from('subscriptions') .from('subscriptions')
.select('*') .select('*')
.eq('id', dataObject.client_reference_id); .eq('id', dataObject.client_reference_id);
console.log(dataObject); console.log(dataObject);
if (subscriptions.length == 0) { if (subscriptions.length == 0) {
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.update({ customerId: dataObject.customer }) .update({ customerId: dataObject.customer })
.eq('id', dataObject.client_reference_id); .eq('id', dataObject.client_reference_id);
if (error) console.log(error); if (error) console.log(error);
await supabase await supabase
.from('subscriptions') .from('subscriptions')
.insert([ .insert([
{ {
id: dataObject.client_reference_id, id: dataObject.client_reference_id,
customer_id: dataObject.customer, customer_id: dataObject.customer,
paid_user: true, paid_user: true,
plan: dataObject.metadata.priceId, plan: dataObject.metadata.priceId,
subscription: dataObject.subscription, subscription: dataObject.subscription,
}, },
]) ])
.then() .then()
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} else if (subscriptions.length > 0) { } else if (subscriptions.length > 0) {
await supabase await supabase
.from('subscriptions') .from('subscriptions')
.update({ .update({
customer_id: dataObject.customer, customer_id: dataObject.customer,
paid_user: true, paid_user: true,
plan: dataObject.metadata.priceId, plan: dataObject.metadata.priceId,
subscription: dataObject.subscription, subscription: dataObject.subscription,
}) })
.eq('id', dataObject.client_reference_id) .eq('id', dataObject.client_reference_id)
.then() .then()
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} }
break; break;
case 'customer.subscription.deleted': case 'customer.subscription.deleted':
await supabase await supabase
.from('subscriptions') .from('subscriptions')
.update({ paid_user: false }) .update({ paid_user: false })
.eq('customer_id', dataObject.customer) .eq('customer_id', dataObject.customer)
.then() .then()
.catch((err) => console.log(err)); .catch((err) => console.log(err));
break; break;
case 'invoice.payment_failed': case 'invoice.payment_failed':
// If the payment fails or the customer does not have a valid payment method, // If the payment fails or the customer does not have a valid payment method,
// an invoice.payment_failed event is sent, the subscription becomes past_due. // an invoice.payment_failed event is sent, the subscription becomes past_due.
// Use this webhook to notify your user that their payment has // Use this webhook to notify your user that their payment has
// failed and to retrieve new card details. // failed and to retrieve new card details.
break; break;
case 'invoice.paid': case 'invoice.paid':
// Used to provision services after the trial has ended. // Used to provision services after the trial has ended.
// The status of the invoice will show up as paid. Store the status in your // The status of the invoice will show up as paid. Store the status in your
// database to reference when a user accesses your service to avoid hitting rate limits. // database to reference when a user accesses your service to avoid hitting rate limits.
break; break;
default: default:
// Unexpected event type // Unexpected event type
} }
res.send(200); res.send(200);
} }
} }

View File

@@ -3,15 +3,15 @@ import Layout from 'components/Layout';
import { NextSeo } from 'next-seo'; import { NextSeo } from 'next-seo';
const ContactPage = () => ( const ContactPage = () => (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Contact`} title={`${process.env.NEXT_PUBLIC_TITLE} | Contact`}
description={`This is the contact page for ${process.env.NEXT_PUBLIC_TITLE}`} description={`This is the contact page for ${process.env.NEXT_PUBLIC_TITLE}`}
/> />
<Layout> <Layout>
<Contact /> <Contact />
</Layout> </Layout>
</> </>
); );
export default ContactPage; export default ContactPage;

View File

@@ -1,95 +1,90 @@
import * as Stripe from 'stripe';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Dashboard from '../components/Dashboard';
import Head from 'next/head'; import Head from 'next/head';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { useRouter } from 'next/router';
import { supabase } from '../utils/supabaseClient'; import { supabase } from '../utils/supabaseClient';
import Dashboard from '../components/Dashboard'; import { useRouter } from 'next/router';
const DashboardPage = ({ user, plan, profile }) => { const DashboardPage = ({ user, plan, profile }) => {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
// If a user is not logged in, return to the homepage // If a user is not logged in, return to the homepage
if (!user) { if (!user) {
router.push('/'); router.push('/');
} }
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
setSession(supabase.auth.session()); setSession(supabase.auth.session());
supabase.auth.onAuthStateChange((_event, session) => { supabase.auth.onAuthStateChange((_event, session) => {
setSession(session); setSession(session);
}); });
}, []); }, []);
return ( return (
<div> <div>
<Head> <Head>
<title>{process.env.NEXT_PUBLIC_TITLE} | Dashboard</title> <title>{process.env.NEXT_PUBLIC_TITLE} | Dashboard</title>
</Head> </Head>
<Layout> <Layout>
{!session ? ( {!session ? (
<div className="text-center">You are not logged in</div> <div className="text-center">You are not logged in</div>
) : ( ) : (
<> <>
<Dashboard <Dashboard key={user.id} session={session} plan={plan} profile={profile} />
key={user.id} </>
session={session} )}
plan={plan} </Layout>
profile={profile} </div>
/> );
</>
)}
</Layout>
</div>
);
}; };
export async function getServerSideProps({ req }) { export async function getServerSideProps({ req }) {
const supabaseAdmin = createClient( const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_ADMIN_KEY process.env.SUPABASE_ADMIN_KEY
); );
const { user } = await supabaseAdmin.auth.api.getUserByCookie(req); const { user } = await supabaseAdmin.auth.api.getUserByCookie(req);
const stripe = require('stripe')(process.env.STRIPE_SECRET); const stripe = new Stripe(process.env.STRIPE_SECRET);
// If the user exist, you will retrieve the user profile and if he/she's a paid user // If the user exist, you will retrieve the user profile and if he/she's a paid user
if (user) { if (user) {
const { data: plan, error } = await supabaseAdmin const { data: plan, error } = await supabaseAdmin
.from('subscriptions') .from('subscriptions')
.select('plan') .select('plan')
.eq('id', user.id) .eq('id', user.id)
.single(); .single();
// Check the subscription plan. If it doesnt exist, return null // Check the subscription plan. If it doesnt exist, return null
const subscription = plan?.plan const subscription = plan?.plan ? await stripe.subscriptions.retrieve(plan.plan) : null;
? await stripe.subscriptions.retrieve(plan.plan)
: null;
const { data: profile, errorProfile } = await supabaseAdmin const { data: profile, errorProfile } = await supabaseAdmin
.from('profiles') .from('profiles')
.select(`username, website, avatar_url`) .select(`username, website, avatar_url`)
.eq('id', user.id) .eq('id', user.id)
.single(); .single();
return { return {
props: { props: {
user, user,
plan: subscription?.plan?.id ? subscription.plan.id : null, plan: subscription?.plan?.id ? subscription.plan.id : null,
profile, profile,
}, },
}; };
} }
if (!user) { if (!user) {
// If no user, redirect to index. // If no user, redirect to index.
return { props: {}, redirect: { destination: '/', permanent: false } }; return { props: {}, redirect: { destination: '/', permanent: false } };
} }
// If there is a user, return it. // If there is a user, return it.
} }
export default DashboardPage; export default DashboardPage;

View File

@@ -8,50 +8,38 @@ import Landing from 'components/Landing';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
const Home = () => ( const Home = () => (
<> <>
<Head> <Head>
<title>{`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`}</title> <title>{`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`}</title>
<meta <meta
name="description" name="description"
content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS" content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS"
/> />
<meta property="og:url" content="https://supanextail.dev/" /> <meta property="og:url" content="https://supanextail.dev/" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta <meta property="og:title" content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} />
property="og:title" <meta
content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} property="og:description"
/> content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS"
<meta />
property="og:description" <meta property="og:image" content="https://supanextail.dev/ogimage.png" />
content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS"
/>
<meta property="og:image" content="https://supanextail.dev/ogimage.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="supanextail.dev" /> <meta property="twitter:domain" content="supanextail.dev" />
<meta <meta property="twitter:url" content="https://supanextail.dev/ogimage.png" />
property="twitter:url" <meta name="twitter:title" content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} />
content="https://supanextail.dev/ogimage.png" <meta
/> name="twitter:description"
<meta content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS"
name="twitter:title" />
content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} <meta name="twitter:image" content="https://supanextail.dev/ogimage.png" />
/> <meta charSet="UTF-8" />
<meta </Head>
name="twitter:description"
content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS"
/>
<meta
name="twitter:image"
content="https://supanextail.dev/ogimage.png"
/>
<meta charSet="UTF-8" />
</Head>
<Layout> <Layout>
<Landing /> <Landing />
</Layout> </Layout>
</> </>
); );
export default Home; export default Home;

View File

@@ -10,26 +10,21 @@ import { NextSeo } from 'next-seo';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
const LoginPage = () => { const LoginPage = () => {
const { signUp, signIn, signOut, resetPassword } = useAuth(); const { signUp, signIn, signOut, resetPassword } = useAuth();
return ( return (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Auth`} title={`${process.env.NEXT_PUBLIC_TITLE} | Auth`}
description={`This is the auth page for ${process.env.NEXT_PUBLIC_TITLE}`} description={`This is the auth page for ${process.env.NEXT_PUBLIC_TITLE}`}
/> />
<Layout> <Layout>
<div className="flex flex-wrap justify-evenly w-full mt-20"> <div className="flex flex-wrap justify-evenly w-full mt-20">
<Login <Login signUp={signUp} signIn={signIn} signOut={signOut} resetPassword={resetPassword} />
signUp={signUp} </div>
signIn={signIn} </Layout>
signOut={signOut} </>
resetPassword={resetPassword} );
/>
</div>
</Layout>
</>
);
}; };
export default LoginPage; export default LoginPage;

View File

@@ -5,14 +5,14 @@ import { NextSeo } from 'next-seo';
import Pricing from 'components/Pricing'; import Pricing from 'components/Pricing';
const PricingPage = () => ( const PricingPage = () => (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Pricing`} title={`${process.env.NEXT_PUBLIC_TITLE} | Pricing`}
description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS" description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS"
/> />
<Layout> <Layout>
<Pricing /> <Pricing />
</Layout> </Layout>
</> </>
); );
export default PricingPage; export default PricingPage;

View File

@@ -5,14 +5,14 @@ import { NextSeo } from 'next-seo';
import PrivacyPolicy from 'components/PrivacyPolicy'; import PrivacyPolicy from 'components/PrivacyPolicy';
const PrivacyPage = () => ( const PrivacyPage = () => (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Privacy Policy`} title={`${process.env.NEXT_PUBLIC_TITLE} | Privacy Policy`}
description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS" description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS"
/> />
<Layout> <Layout>
<PrivacyPolicy /> <PrivacyPolicy />
</Layout> </Layout>
</> </>
); );
export default PrivacyPage; export default PrivacyPage;

View File

@@ -10,19 +10,19 @@ import Layout from 'components/Layout';
import { NextSeo } from 'next-seo'; import { NextSeo } from 'next-seo';
const SignUpPage = () => ( const SignUpPage = () => (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Auth`} title={`${process.env.NEXT_PUBLIC_TITLE} | Auth`}
description={`This is the auth page for ${process.env.NEXT_PUBLIC_TITLE}`} description={`This is the auth page for ${process.env.NEXT_PUBLIC_TITLE}`}
/> />
<Layout> <Layout>
<div className="flex flex-wrap justify-evenly w-full mt-20"> <div className="flex flex-wrap justify-evenly w-full mt-20">
<AuthText /> <AuthText />
<AuthComponent /> <AuthComponent />
</div> </div>
</Layout> </Layout>
</> </>
); );
export default SignUpPage; export default SignUpPage;

View File

@@ -5,14 +5,14 @@ import { NextSeo } from 'next-seo';
import Terms from 'components/Terms'; import Terms from 'components/Terms';
const TermsPage = () => ( const TermsPage = () => (
<> <>
<NextSeo <NextSeo
title={`${process.env.NEXT_PUBLIC_TITLE} | Terms and conditions`} title={`${process.env.NEXT_PUBLIC_TITLE} | Terms and conditions`}
description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS" description="SupaNexTail is a boilerplate for your website, based on Next.js, Supabase, and TailwindCSS"
/> />
<Layout> <Layout>
<Terms /> <Terms />
</Layout> </Layout>
</> </>
); );
export default TermsPage; export default TermsPage;

View File

@@ -1,8 +1,8 @@
// If you want to use other PostCSS plugins, see the following: // If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors // https://tailwindcss.com/docs/using-with-preprocessors
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@@ -1,76 +1,76 @@
module.exports = { module.exports = {
mode: 'jit', mode: 'jit',
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class' darkMode: false, // or 'media' or 'class'
theme: { theme: {
fontFamily: { fontFamily: {
title: ['Poppins'], title: ['Poppins'],
body: ['Inter'], body: ['Inter'],
}, },
extend: {}, extend: {},
}, },
variants: { variants: {
extend: {}, extend: {},
}, },
plugins: [require('daisyui')], plugins: [require('daisyui')],
daisyui: { daisyui: {
themes: [ themes: [
{ {
supaTheme: { supaTheme: {
primary: '#00B8F0', primary: '#00B8F0',
'primary-focus': '#009de0', 'primary-focus': '#009de0',
'primary-content': '#ffffff', 'primary-content': '#ffffff',
secondary: '#f03800', secondary: '#f03800',
'secondary-focus': '#e22f00', 'secondary-focus': '#e22f00',
'secondary-content': '#ffffff', 'secondary-content': '#ffffff',
accent: '#00f0b0', accent: '#00f0b0',
'accent-focus': '#00e28a', 'accent-focus': '#00e28a',
'accent-content': '#ffffff', 'accent-content': '#ffffff',
neutral: '#3d4451', neutral: '#3d4451',
'neutral-focus': '#2a2e37', 'neutral-focus': '#2a2e37',
'neutral-content': '#ffffff', 'neutral-content': '#ffffff',
'base-100': '#ffffff', 'base-100': '#ffffff',
'base-200': '#767676', 'base-200': '#767676',
'base-300': '#d1d5db', 'base-300': '#d1d5db',
'base-content': '#1f2937', 'base-content': '#1f2937',
info: '#2094f3' /* Info */, info: '#2094f3' /* Info */,
success: '#009485' /* Success */, success: '#009485' /* Success */,
warning: '#ff9900' /* Warning */, warning: '#ff9900' /* Warning */,
error: '#ff5724' /* Error */, error: '#ff5724' /* Error */,
}, },
dark: { dark: {
primary: '#00B8F0', primary: '#00B8F0',
'primary-focus': '#009de0', 'primary-focus': '#009de0',
'primary-content': '#ffffff', 'primary-content': '#ffffff',
secondary: '#f03800', secondary: '#f03800',
'secondary-focus': '#e22f00', 'secondary-focus': '#e22f00',
'secondary-content': '#ffffff', 'secondary-content': '#ffffff',
accent: '#00f0b0', accent: '#00f0b0',
'accent-focus': '#00e28a', 'accent-focus': '#00e28a',
'accent-content': '#ffffff', 'accent-content': '#ffffff',
neutral: '#3d4451', neutral: '#3d4451',
'neutral-focus': '#2a2e37', 'neutral-focus': '#2a2e37',
'neutral-content': '#ffffff', 'neutral-content': '#ffffff',
'base-100': '#2A2E37', 'base-100': '#2A2E37',
'base-200': '#EBECF0', 'base-200': '#EBECF0',
'base-300': '#16181D', 'base-300': '#16181D',
'base-content': '#EBECF0', 'base-content': '#EBECF0',
info: '#2094f3', info: '#2094f3',
success: '#009485', success: '#009485',
warning: '#ff9900', warning: '#ff9900',
error: '#ff5724', error: '#ff5724',
}, },
}, },
], ],
}, },
}; };

74
tsconfig.json Normal file
View File

@@ -0,0 +1,74 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
"removeComments": false, /* Do not emit comments to output. */// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. *//* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. *//* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
"inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
"inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. *//* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -6,53 +6,47 @@ import { supabase } from 'utils/supabaseClient';
const AuthContext = createContext(); const AuthContext = createContext();
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(); const [user, setUser] = useState();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Check active sessions and sets the user // Check active sessions and sets the user
const session = supabase.auth.session(); const session = supabase.auth.session();
setUser(session?.user ?? null); setUser(session?.user ?? null);
setLoading(false); setLoading(false);
// Listen for changes on auth state (logged in, signed out, etc.) // Listen for changes on auth state (logged in, signed out, etc.)
const { data: listener } = supabase.auth.onAuthStateChange( const { data: listener } = supabase.auth.onAuthStateChange(async (event, session) => {
async (event, session) => { if ((event === 'SIGNED_OUT') | (event === 'SIGNED_IN')) {
if ((event === 'SIGNED_OUT') | (event === 'SIGNED_IN')) { fetch('/api/auth', {
fetch('/api/auth', { method: 'POST',
method: 'POST', headers: new Headers({ 'Content-Type': 'application/json' }),
headers: new Headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin',
credentials: 'same-origin', body: JSON.stringify({ event, session }),
body: JSON.stringify({ event, session }), }).then((res) => res.json());
}).then((res) => res.json()); }
} if (event === 'USER_UPDATED') {
if (event === 'USER_UPDATED') { }
} setUser(session?.user ?? null);
setUser(session?.user ?? null); setLoading(false);
setLoading(false); });
}
);
return () => { return () => {
listener?.unsubscribe(); listener?.unsubscribe();
}; };
}, []); }, []);
// Will be passed down to Signup, Login and Dashboard components // Will be passed down to Signup, Login and Dashboard components
const value = { const value = {
signUp: (data) => supabase.auth.signUp(data), signUp: (data) => supabase.auth.signUp(data),
signIn: (data) => supabase.auth.signIn(data), signIn: (data) => supabase.auth.signIn(data),
signOut: () => supabase.auth.signOut(), signOut: () => supabase.auth.signOut(),
resetPassword: (data) => supabase.auth.api.resetPasswordForEmail(data), resetPassword: (data) => supabase.auth.api.resetPasswordForEmail(data),
user, user,
}; };
return ( return <AuthContext.Provider value={value}>{!loading && children}</AuthContext.Provider>;
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}; };
// export the useAuth hook // export the useAuth hook

View File

@@ -1,13 +1,13 @@
// Helper method to wait for a middleware to execute before continuing // Helper method to wait for a middleware to execute before continuing
// And to throw an error when an error happens in a middleware // And to throw an error when an error happens in a middleware
export default function initMiddleware(middleware) { export default function initMiddleware(middleware) {
return (req, res) => return (req, res) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
middleware(req, res, (result) => { middleware(req, res, (result) => {
if (result instanceof Error) { if (result instanceof Error) {
return reject(result); return reject(result);
} }
return resolve(result); return resolve(result);
}); });
}); });
} }

View File

@@ -1,45 +1,45 @@
// You can store your price IDs from Stripe here // You can store your price IDs from Stripe here
const Prices = { const Prices = {
personal: { personal: {
monthly: { monthly: {
id: 'price_1J5q2yDMjD0UnVmMXzEWYDnl', id: 'price_1J5q2yDMjD0UnVmMXzEWYDnl',
desc: 'Personal plan (monthly)', desc: 'Personal plan (monthly)',
}, },
annually: { annually: {
id: 'price_1J5q45DMjD0UnVmMQxXHKGAv', id: 'price_1J5q45DMjD0UnVmMQxXHKGAv',
desc: 'Personal plan (annually)', desc: 'Personal plan (annually)',
}, },
}, },
team: { team: {
monthly: { monthly: {
id: 'price_1J5q3GDMjD0UnVmMlHc5Eedq', id: 'price_1J5q3GDMjD0UnVmMlHc5Eedq',
desc: 'Team plan (monthly)', desc: 'Team plan (monthly)',
}, },
annually: { annually: {
id: 'price_1J5q8zDMjD0UnVmMqsngM91X', id: 'price_1J5q8zDMjD0UnVmMqsngM91X',
desc: 'Team plan (annually)', desc: 'Team plan (annually)',
}, },
}, },
pro: { pro: {
monthly: { monthly: {
id: 'price_1J6KRuDMjD0UnVmMIItaOdT3', id: 'price_1J6KRuDMjD0UnVmMIItaOdT3',
desc: 'Pro plan (monthly)', desc: 'Pro plan (monthly)',
}, },
annually: { annually: {
id: 'price_1J5q9VDMjD0UnVmMIQtVDSZ9', id: 'price_1J5q9VDMjD0UnVmMIQtVDSZ9',
desc: 'Pro plan (annually)', desc: 'Pro plan (annually)',
}, },
}, },
}; };
const PriceIds = { const PriceIds = {
price_1J5q2yDMjD0UnVmMXzEWYDnl: 'Personal plan (monthly)', price_1J5q2yDMjD0UnVmMXzEWYDnl: 'Personal plan (monthly)',
price_1J5q45DMjD0UnVmMQxXHKGAv: 'Personal plan (annually)', price_1J5q45DMjD0UnVmMQxXHKGAv: 'Personal plan (annually)',
price_1J5q3GDMjD0UnVmMlHc5Eedq: 'Team plan (monthly)', price_1J5q3GDMjD0UnVmMlHc5Eedq: 'Team plan (monthly)',
price_1J5q8zDMjD0UnVmMqsngM91X: 'Team plan (annually)', price_1J5q8zDMjD0UnVmMqsngM91X: 'Team plan (annually)',
price_1J6KRuDMjD0UnVmMIItaOdT3: 'Pro plan (monthly)', price_1J6KRuDMjD0UnVmMIItaOdT3: 'Pro plan (monthly)',
price_1J5q9VDMjD0UnVmMIQtVDSZ9: 'Pro plan (annually)', price_1J5q9VDMjD0UnVmMIQtVDSZ9: 'Pro plan (annually)',
}; };
export { Prices, PriceIds }; export { Prices, PriceIds };

View File

@@ -5,10 +5,10 @@ import { loadStripe } from '@stripe/stripe-js';
let stripePromise = null; let stripePromise = null;
const getStripe = () => { const getStripe = () => {
if (!stripePromise) { if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY); stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
} }
return stripePromise; return stripePromise;
}; };
export default getStripe; export default getStripe;

View File

@@ -7,11 +7,11 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Check if a user has a paid plan // Check if a user has a paid plan
export const getSub = async () => { export const getSub = async () => {
const { data: subscriptions, error } = await supabase const { data: subscriptions, error } = await supabase
.from('subscriptions') .from('subscriptions')
.select('paid_user, plan') .select('paid_user, plan')
.single(); .single();
if (subscriptions) { if (subscriptions) {
return subscriptions; return subscriptions;
} }
}; };