First pass prettier/eslint and headwind

This commit is contained in:
Michael
2021-12-16 23:45:43 +01:00
parent ab9e3732d6
commit be7580c04d
47 changed files with 2149 additions and 1939 deletions

View File

@@ -8,8 +8,7 @@
"extends": [ "extends": [
"next", "next",
"next/core-web-vitals", "next/core-web-vitals",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking"
"prettier"
], ],
"parserOptions": { "parserOptions": {
"ecmaFeatures": { "ecmaFeatures": {
@@ -30,9 +29,18 @@
"prettier/prettier": [ "prettier/prettier": [
"error", "error",
{ {
"endOfLine": "auto" "semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "auto",
"printWidth": 80,
"bracketSameLine": true,
"bracketSpacing": true
} }
], ],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
2, 2,
@@ -44,6 +52,15 @@
".tsx" ".tsx"
] ]
} }
],
"@typescript-eslint/explicit-function-return-type": [
"warn",
{
"allowExpressions": true,
"allowTypedFunctionExpressions": true,
"allowHigherOrderFunctions": true,
"allowConciseArrowFunctionExpressionsStartingWithVoid": true
}
] ]
}, },
"settings": { "settings": {

View File

@@ -3,7 +3,9 @@
"trailingComma": "es5", "trailingComma": "es5",
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"useTabs": true, "useTabs": false,
"endOfLine": "auto", "endOfLine": "auto",
"printWidth": 100 "printWidth": 80,
"bracketSameLine": true,
"bracketSpacing": true
} }

View File

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

View File

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

View File

@@ -8,48 +8,53 @@ import cardStripe from 'public/landing/stripe.svg';
import cardTheme from 'public/landing/theme.svg'; import cardTheme from 'public/landing/theme.svg';
const CardsLanding = (): JSX.Element => ( const CardsLanding = (): JSX.Element => (
<div className="mt-14"> <div className="mt-14">
<h2 className="uppercase font-bold text-4xl tracking-wide text-center"> <h2 className="text-4xl font-bold tracking-wide text-center uppercase">
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 maximum of features Dont waste your time and reinvent the wheel, we have provided you with a
so that you only have one goal, to make your SaaS a reality. maximum of features so that you only have one goal, to make your SaaS a
</p> reality.
<div className="flex flex-wrap justify-center mt-10"> </p>
<CardLanding <div className="flex flex-wrap justify-center mt-10">
image={cardPage} <CardLanding
text="7 pages fully designed and easily customizable" image={cardPage}
title="Templates" text="7 pages fully designed and easily customizable"
/> title="Templates"
<CardLanding />
image={cardServer} <CardLanding
text="Integrated backend already setup with Next.js API Routes" image={cardServer}
title="Backend" text="Integrated backend already setup with Next.js API Routes"
/> title="Backend"
<CardLanding image={cardAuth} text="Auth and user management with Supabase" title="Auth" /> />
<CardLanding <CardLanding
image={cardResponsive} image={cardAuth}
text="Mobile ready, fully responsive and customizable with Tailwind CSS" text="Auth and user management with Supabase"
title="Responsive" title="Auth"
/> />
<CardLanding <CardLanding
image={cardTheme} image={cardResponsive}
text="Custom themes available and easily switch to dark mode" text="Mobile ready, fully responsive and customizable with Tailwind CSS"
title="Themes" title="Responsive"
/> />
<CardLanding <CardLanding
image={cardStripe} image={cardTheme}
text="Stripe integration. Fully functional subscription system" text="Custom themes available and easily switch to dark mode"
title="Payment" title="Themes"
/> />
<CardLanding <CardLanding
image={cardFee} image={cardStripe}
text="One-time fee. No subscription, youll have access to all the updates" text="Stripe integration. Fully functional subscription system"
title="Lifetime access" title="Payment"
/> />
</div> <CardLanding
</div> image={cardFee}
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 = (): JSX.Element => { const Contact = (): JSX.Element => {
const sendEmail = () => { const sendEmail = () => {
const name = (document.getElementById('name') as HTMLInputElement).value; const name = (document.getElementById('name') as HTMLInputElement).value;
const email = (document.getElementById('email') as HTMLInputElement).value; const email = (document.getElementById('email') as HTMLInputElement).value;
const message = (document.getElementById('message') as HTMLInputElement).value; const message = (document.getElementById('message') as HTMLInputElement)
.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') as HTMLInputElement).value = ''; (document.getElementById('name') as HTMLInputElement).value = '';
(document.getElementById('email') as HTMLInputElement).value = ''; (document.getElementById('email') as HTMLInputElement).value = '';
(document.getElementById('message') as HTMLInputElement).value = ''; (document.getElementById('message') as HTMLInputElement).value = '';
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
}); });
} else { } else {
toast.info('Please fill all the fields ', { toast.info('Please fill all the fields ', {
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 px-5 py-10 m-auto">
<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="mt-0 mb-5 text-3xl font-bold text-center sm:text-4xl 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 to integrate? A bug Do you have a question about SupaNexTail? A cool feature you'd like us
to report? Don't hesitate! to integrate? A bug 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="grid grid-cols-1 gap-4 p-5 m-auto mt-5 md:grid-cols-2">
<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="mb-4 font-light 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="mb-4 font-light 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="mb-4 font-light 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="w-full h-32 pt-2 resize-none input input-primary input-bordered"
/> />
</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,133 +17,138 @@ import { toast } from 'react-toastify';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
type DashboardProps = { type DashboardProps = {
profile: { username: string; website: string; avatar_url: string }; profile: { username: string; website: string; avatar_url: string };
session: Session; session: Session;
planName: string; planName: string;
}; };
const Dashboard = ({ profile, session, planName }: DashboardProps): JSX.Element => { const Dashboard = ({
const router = useRouter(); profile,
const [loading, setLoading] = useState(false); session,
const [username, setUsername] = useState(profile?.username || ''); planName,
const [website, setWebsite] = useState(profile?.website || ''); }: DashboardProps): JSX.Element => {
const [avatar_url, setAvatarUrl] = useState(profile?.avatar_url || ''); const router = useRouter();
const [payment, setPayment] = useState(false); const [loading, setLoading] = useState(false);
const [username, setUsername] = useState(profile?.username || '');
const [website, setWebsite] = useState(profile?.website || '');
const [avatar_url, setAvatarUrl] = useState(profile?.avatar_url || '');
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);
} }
}, [router.query.session_id]); }, [router.query.session_id]);
async function updateProfile({ async function updateProfile({
username, username,
website, website,
avatar_url, avatar_url,
}: { }: {
username: string; username: string;
website: string; website: string;
avatar_url: string; avatar_url: string;
}) { }) {
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: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof 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 w-full max-w-xl px-5 py-10 m-auto text-left">
<div className="max-w-sm flex flex-col justify-center m-auto w-full p-5"> <div className="flex flex-col justify-center w-full max-w-sm p-5 m-auto">
<h1 className="text-4xl font-bold md:text-5xl font-title text-center mb-10">Dashboard</h1> <h1 className="mb-10 text-4xl font-bold text-center md:text-5xl font-title">
<Avatar Dashboard
url={avatar_url} </h1>
size={150} <Avatar
onUpload={(url) => { url={avatar_url}
setAvatarUrl(url); size={150}
updateProfile({ username, website, avatar_url: url }); onUpload={(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"> />
Email <div className="flex flex-col mb-5">
</label> <label htmlFor="email" className="my-auto mb-2 text-sm">
<input Email
className="input input-primary input-bordered input-sm flex-1 text-base-100" </label>
id="email" <input
type="text" className="flex-1 input input-primary input-bordered input-sm text-base-100"
value={session.user?.email} id="email"
disabled type="text"
/> value={session.user?.email}
</div> disabled
<div className="mb-5 flex flex-col"> />
<label htmlFor="username" className="my-auto text-sm mb-2"> </div>
Name <div className="flex flex-col mb-5">
</label> <label htmlFor="username" className="my-auto mb-2 text-sm">
<input Name
className="input input-primary input-bordered input-sm flex-1" </label>
id="username" <input
type="text" className="flex-1 input input-primary input-bordered input-sm"
value={username || ''} id="username"
onChange={(e) => setUsername(e.target.value)} type="text"
/> value={username || ''}
</div> onChange={(e) => setUsername(e.target.value)}
<div className="mb-5 flex flex-col"> />
<label htmlFor="website" className="my-auto text-sm mb-2"> </div>
Website <div className="flex flex-col mb-5">
</label> <label htmlFor="website" className="my-auto mb-2 text-sm">
<input Website
className="input input-primary input-bordered input-sm flex-1" </label>
id="website" <input
type="website" className="flex-1 input input-primary input-bordered input-sm"
value={website || ''} id="website"
onChange={(e) => setWebsite(e.target.value)} type="website"
/> value={website || ''}
</div> onChange={(e) => setWebsite(e.target.value)}
/>
</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="flex flex-row flex-wrap w-full max-w-xl p-5 m-auto my-5 border-2 shadow-lg bordered border-primary">
<Image src={Plan} alt="credit card" /> <Image src={Plan} alt="credit card" />
<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="">{planName}</p> <p className="">{planName}</p>
</div> </div>
</div> </div>
<PaymentModal open={payment} setPayment={setPayment} /> <PaymentModal open={payment} setPayment={setPayment} />
</div> </div>
); );
}; };
export default Dashboard; export default Dashboard;

View File

@@ -2,27 +2,27 @@ import Link from 'next/link';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const Footer = (): JSX.Element => { const Footer = (): JSX.Element => {
const ThemeToggle = dynamic(() => import('components/UI/ThemeToggle'), { const ThemeToggle = dynamic(() => import('components/UI/ThemeToggle'), {
ssr: false, ssr: false,
}); });
return ( return (
<footer className="w-full flex"> <footer className="flex w-full">
<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 w-full justify-evenly sm:flex-row 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="my-auto mr-5">
<ThemeToggle /> <ThemeToggle />
</div> </div>
</footer> </footer>
); );
}; };
export default Footer; export default Footer;

View File

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

View File

@@ -19,31 +19,45 @@ import { ToastContainer } from 'react-toastify';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
type LayoutProps = { type LayoutProps = {
children: JSX.Element; children: JSX.Element;
}; };
const Layout = ({ children }: LayoutProps): JSX.Element => { const Layout = ({ children }: LayoutProps): JSX.Element => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
return ( return (
<div className="min-h-screen w-full bg-base-100 text-base-content m-auto font-body"> <div className="w-full min-h-screen m-auto bg-base-100 text-base-content font-body">
<Head> <Head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> rel="apple-touch-icon"
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> sizes="180x180"
<link rel="manifest" href="/site.webmanifest" /> href="/apple-touch-icon.png"
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" /> />
<meta name="msapplication-TileColor" content="#da532c" /> <link
<meta name="theme-color" content="#ffffff" /> rel="icon"
</Head> type="image/png"
<div className="max-w-7xl flex flex-col min-h-screen mx-auto p-5"> sizes="32x32"
<Nav user={user} signOut={signOut} /> href="/favicon-32x32.png"
<main className="flex-1">{children}</main> />
<ToastContainer position="bottom-center" /> <link
<Footer /> rel="icon"
</div> type="image/png"
</div> sizes="16x16"
); href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
</Head>
<div className="flex flex-col min-h-screen p-5 mx-auto max-w-7xl">
<Nav user={user} signOut={signOut} />
<main className="flex-1">{children}</main>
<ToastContainer position="bottom-center" />
<Footer />
</div>
</div>
);
}; };
export default Layout; export default Layout;

View File

@@ -10,73 +10,76 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
const MailingList = (): JSX.Element => { const MailingList = (): JSX.Element => {
const [mail, setMail] = useState(''); const [mail, setMail] = useState('');
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="flex flex-col m-auto my-10 mt-24">
<h2 className="text-3xl md:text-4xl font-bold font-title uppercase text-center"> <h2 className="text-3xl font-bold text-center uppercase md:text-4xl font-title">
Stay Tuned Stay Tuned
</h2> </h2>
<Image src={Mailing} alt="Mail" /> <Image src={Mailing} alt="Mail" />
<label className="label"> <label className="label">
<p className="text-center max-w-md m-auto"> <p className="max-w-md m-auto text-center">
Want to be the first to know when SupaNexTail launches and get an exclusive discount? Sign Want to be the first to know when SupaNexTail launches and get an
up for the newsletter! exclusive discount? Sign up for the newsletter!
</p> </p>
</label> </label>
<div className="mt-5 m-auto"> <div className="m-auto mt-5">
<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 ${valid ? null : 'input-error'}`} className={`input input-primary input-bordered ${
/> valid ? null : 'input-error'
<button }`}
onClick={validateEmail} />
className={`btn ml-3 ${loading ? 'btn-disabled loading' : 'btn-primary'}`} <button
> onClick={validateEmail}
I'm in! className={`btn ml-3 ${
</button> loading ? 'btn-disabled loading' : 'btn-primary'
</div> }`}>
</div> I'm in!
); </button>
</div>
</div>
);
}; };
export default MailingList; export default MailingList;

View File

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

View File

@@ -3,69 +3,76 @@ import { Dialog, Transition } from '@headlessui/react';
import { Fragment } from 'react'; import { Fragment } from 'react';
type PaymentModalProps = { type PaymentModalProps = {
open: boolean; open: boolean;
setPayment: (arg0: boolean) => void; setPayment: (arg0: boolean) => void;
}; };
const PaymentModal = ({ open, setPayment }: PaymentModalProps): JSX.Element => { const PaymentModal = ({ open, setPayment }: PaymentModalProps): JSX.Element => {
function closeModal() { function closeModal() {
setPayment(false); setPayment(false);
} }
return ( return (
<> <>
<Transition appear show={open} as={Fragment}> <Transition appear show={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" />
> </Transition.Child>
<Dialog.Overlay className="fixed inset-0" />
</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 className="inline-block h-screen align-middle" aria-hidden="true"> <span
&#8203; className="inline-block h-screen align-middle"
</span> aria-hidden="true">
<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"> <div className="inline-block w-full max-w-lg p-8 my-8 overflow-hidden text-left align-middle transition-all transform border-2 shadow-xl rounded-2xl bg-base-100 text-base-content border-accent-focus">
Payment successful 🎉 <Dialog.Title
</Dialog.Title> as="h3"
<div className="mt-2"> className="mb-5 text-2xl font-bold leading-6 text-center">
<p>Your payment has been successfully submitted. Thank you for your support!</p> Payment successful 🎉
</div> </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 type="button" className="btn btn-accent flex m-auto" onClick={closeModal}> <button
Got it, thanks! type="button"
</button> className="flex m-auto btn btn-accent"
</div> onClick={closeModal}>
</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

@@ -14,140 +14,145 @@ import router from 'next/router';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
const Pricing = (): JSX.Element => { const Pricing = (): JSX.Element => {
const { user, session } = useAuth(); const { user, session } = useAuth();
const [customerId, setCustomerId] = useState<null | string>(null); const [customerId, setCustomerId] = useState<null | string>(null);
const [sub, setSub] = useState(false); const [sub, setSub] = useState(false);
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 handleSubmit = async (e: React.SyntheticEvent<HTMLButtonElement>, priceId: string) => { const handleSubmit = async (
e.preventDefault(); e: React.SyntheticEvent<HTMLButtonElement>,
// Create a Checkout Session. This will redirect the user to the Stripe website for the payment. priceId: string
if (sub) { ) => {
axios e.preventDefault();
.post('/api/stripe/customer-portal', { // Create a Checkout Session. This will redirect the user to the Stripe website for the payment.
customerId, if (sub) {
}) axios
.then((result) => { .post('/api/stripe/customer-portal', {
router.push(result.data.url); customerId,
}); })
} else .then((result) => {
axios router.push(result.data.url);
.post('/api/stripe/create-checkout-session', { });
priceId, } else
email: user.email, axios
customerId, .post('/api/stripe/create-checkout-session', {
userId: user.id, priceId,
tokenId: session.access_token, email: user.email,
pay_mode: 'subscription', customerId,
}) userId: user.id,
.then((result) => router.push(result.data.url)); tokenId: session.access_token,
}; pay_mode: 'subscription',
return ( })
<div> .then((result) => router.push(result.data.url));
<div className="container px-6 py-8 mx-auto text-base-100"> };
<h2 className="text-3xl sm:text-4xl text-center mb-5 mt-0 font-bold font-title text-base-content"> return (
Pricing <div>
</h2> <div className="container px-6 py-8 mx-auto text-base-100">
<div className="mt-16 flex flex-col items-center justify-center space-y-8 lg:-mx-4 lg:flex-row lg:items-stretch lg:space-y-0"> <h2 className="mt-0 mb-5 text-3xl font-bold text-center sm:text-4xl font-title text-base-content">
<div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center bg-base-100 rounded-lg lg:mx-4 shadow-lg text-base-content"> Pricing
<div className="flex-shrink-0"> </h2>
<h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100"> <div className="flex flex-col items-center justify-center mt-16 space-y-8 lg:flex-row lg:items-stretch lg:-mx-4 lg:space-y-0">
Casual <div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center rounded-lg shadow-lg lg:mx-4 bg-base-100 text-base-content">
</h3> <div className="flex-shrink-0">
</div> <h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100">
<div className="flex-shrink-0"> Casual
<span className="pt-2 text-4xl font-bold uppercase">FREE</span> </h3>
</div> </div>
<ul className="flex-1 space-y-4 text-base-content"> <div className="flex-shrink-0">
<li>Up to 10 projects</li> <span className="pt-2 text-4xl font-bold uppercase">FREE</span>
<li>Up to 20 collaborators</li> </div>
<li>10Gb of storage</li> <ul className="flex-1 space-y-4 text-base-content">
</ul> <li>Up to 10 projects</li>
<li>Up to 20 collaborators</li>
<li>10Gb of storage</li>
</ul>
<button className="btn btn-primary">Start for free</button> <button className="btn btn-primary">Start for free</button>
</div> </div>
<div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center bg-base-100 rounded-lg lg:mx-4 shadow-lg text-base-content"> <div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center rounded-lg shadow-lg lg:mx-4 bg-base-100 text-base-content">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100"> <h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100">
Professional Professional
</h3> </h3>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<span className="pt-2 text-4xl font-bold uppercase">$4.90</span> <span className="pt-2 text-4xl font-bold uppercase">$4.90</span>
<span>/month</span> <span>/month</span>
</div> </div>
<ul className="flex-1 space-y-4 text-base-content"> <ul className="flex-1 space-y-4 text-base-content">
<li>Up to 30 projects</li> <li>Up to 30 projects</li>
<li>Up to 25 collaborators</li> <li>Up to 25 collaborators</li>
<li>100Gb of storage</li> <li>100Gb of storage</li>
<li>Real-time collaborations</li> <li>Real-time collaborations</li>
</ul> </ul>
{user ? ( {user ? (
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={(e) => { onClick={(e) => {
handleSubmit(e, 'price_1JtHhaDMjD0UnVmM5uCyyrWn'); handleSubmit(e, 'price_1JtHhaDMjD0UnVmM5uCyyrWn');
}} }}>
> {sub ? 'Handle subscription' : 'Subscribe'}
{sub ? 'Handle subscription' : 'Subscribe'} </button>
</button> ) : (
) : ( <button
<button className="btn btn-primary" onClick={() => router.push('/login')}> className="btn btn-primary"
Log in onClick={() => router.push('/login')}>
</button> Log in
)} </button>
</div> )}
</div>
<div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center bg-base-100 rounded-lg lg:mx-4 shadow-lg text-base-content"> <div className="flex flex-col w-full max-w-sm p-8 space-y-8 text-center rounded-lg shadow-lg lg:mx-4 bg-base-100 text-base-content">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100"> <h3 className="inline-flex items-center badge-neutral badge badge-lg bg-base-content text-base-100">
Business Business
</h3> </h3>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<span className="pt-2 text-4xl font-bold uppercase">$24.90</span> <span className="pt-2 text-4xl font-bold uppercase">$24.90</span>
<span>/month</span> <span>/month</span>
</div> </div>
<ul className="flex-1 space-y-4 text-base-content"> <ul className="flex-1 space-y-4 text-base-content">
<li>Up to 60 projects</li> <li>Up to 60 projects</li>
<li>Up to 200 collaborators</li> <li>Up to 200 collaborators</li>
<li>1Tb of storage</li> <li>1Tb of storage</li>
<li>Real-time collaborations</li> <li>Real-time collaborations</li>
</ul> </ul>
{user ? ( {user ? (
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={(e) => { onClick={(e) => {
handleSubmit(e, 'price_1JtHhaDMjD0UnVmM5uCyyrWn'); handleSubmit(e, 'price_1JtHhaDMjD0UnVmM5uCyyrWn');
}} }}>
> {sub ? 'Handle subscription' : 'Subscribe'}
{sub ? 'Handle subscription' : 'Subscribe'} </button>
</button> ) : (
) : ( <button
<button className="btn btn-primary" onClick={() => router.push('/login')}> className="btn btn-primary"
Log in onClick={() => router.push('/login')}>
</button> Log in
)} </button>
</div> )}
</div> </div>
</div> </div>
</div> </div>
); </div>
);
}; };
export default Pricing; export default Pricing;

View File

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

View File

@@ -13,31 +13,31 @@ import { supabase } from 'utils/supabaseClient';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
type ContainerProps = { type ContainerProps = {
children: JSX.Element; children: JSX.Element;
supabaseClient: SupabaseClient; supabaseClient: SupabaseClient;
}; };
const Container = ({ children }: ContainerProps): JSX.Element => { const Container = ({ children }: ContainerProps): JSX.Element => {
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="order-first w-80 md:w-96 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 children; return children;
}; };
const AuthComponent = (): JSX.Element => { const AuthComponent = (): JSX.Element => {
const { signUp, signIn } = useAuth(); const { signUp, signIn } = useAuth();
return ( return (
<Container supabaseClient={supabase}> <Container supabaseClient={supabase}>
<SignUpPanel signUp={signUp} signIn={signIn} /> <SignUpPanel signUp={signUp} signIn={signIn} />
</Container> </Container>
); );
}; };
export default AuthComponent; export default AuthComponent;

View File

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

View File

@@ -5,27 +5,27 @@ This card is used on the landing page
import Image from 'next/image'; import Image from 'next/image';
type CardLandingProps = { type CardLandingProps = {
image: string; image: string;
title: string; title: string;
text: string; text: string;
}; };
const CardLanding = ({ image, title, text }: CardLandingProps): JSX.Element => { const CardLanding = ({ image, title, text }: CardLandingProps): JSX.Element => {
return ( return (
<div className="w-80 h-48 p-5 sm:ml-5 mb-5 bg-base-100 flex"> <div className="flex h-48 p-5 mb-5 w-80 sm:ml-5 bg-base-100">
<div> <div>
<div className="rounded-full w-12 h-12 border flex bg-neutral-content"> <div className="flex w-12 h-12 border rounded-full bg-neutral-content">
<div className="m-auto flex"> <div className="flex m-auto">
<Image src={image} width={24} height={24} alt={`${title} logo`} /> <Image src={image} width={24} height={24} alt={`${title} logo`} />
</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="text-lg font-semibold font-title">{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,16 +5,16 @@ This card is used on the landing page
import { FiStar } from 'react-icons/fi'; import { FiStar } from 'react-icons/fi';
type KeyFeatureProps = { type KeyFeatureProps = {
children: JSX.Element; children: JSX.Element;
}; };
const KeyFeature = ({ children }: KeyFeatureProps): JSX.Element => ( const KeyFeature = ({ children }: KeyFeatureProps): JSX.Element => (
<div className="shadow-sm p-5 mb-5 bg-base-100 flex italic"> <div className="flex p-5 mb-5 italic shadow-sm bg-base-100">
<div className="p-2 bg-accent-focus w-12 h-12 text-white rounded-sm my-auto flex"> <div className="flex w-12 h-12 p-2 my-auto text-white rounded-sm bg-accent-focus">
<FiStar className="text-2xl m-auto" /> <FiStar className="m-auto text-2xl" />
</div> </div>
<div className="m-auto ml-3">{children}</div> <div className="m-auto ml-3">{children}</div>
</div> </div>
); );
export default KeyFeature; export default KeyFeature;

View File

@@ -4,165 +4,174 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
type LoginProps = { type LoginProps = {
resetPassword: (email: string) => Promise<{ error: { message: string } }>; resetPassword: (email: string) => Promise<{ error: { message: string } }>;
signIn: ({}) => Promise<{ data: Record<string, unknown>; error: { message: string } }>; signIn: ({}) => Promise<{
data: Record<string, unknown>;
error: { message: string };
}>;
}; };
const Login = ({ resetPassword, signIn }: LoginProps): JSX.Element => { const Login = ({ resetPassword, signIn }: LoginProps): JSX.Element => {
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 resetPasswordLogin = () => { const resetPasswordLogin = () => {
resetPassword(email).then((result: { error: { message: string } }) => { resetPassword(email).then((result: { error: { message: string } }) => {
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: React.SyntheticEvent<HTMLButtonElement>) => { const login = (e: React.SyntheticEvent<HTMLButtonElement>) => {
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.
signIn({ signIn({
email, email,
password, password,
}).then((result: { data: Record<string, unknown>; error: { message: string } }) => { }).then(
if (result.data) { (result: {
router.push('/'); data: Record<string, unknown>;
} error: { message: string };
if (result.error) { }) => {
toast.error(result.error.message); if (result.data) {
} router.push('/');
}); }
}; if (result.error) {
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="max-w-sm p-10 rounded-md shadow-md md:flex-1 bg-base-100 text-base-content font-body">
{!forgot && ( {!forgot && (
<> <>
<h3 className="my-4 text-2xl font-semibold font-title">Account Login</h3> <h3 className="my-4 text-2xl font-semibold font-title">
<form action="#" className="flex flex-col space-y-5"> Account Login
<div className="flex flex-col space-y-1"> </h3>
<label htmlFor="email" className="text-sm"> <form action="#" className="flex flex-col space-y-5">
Email address <div className="flex flex-col space-y-1">
</label> <label htmlFor="email" className="text-sm">
<input Email address
type="email" </label>
id="email" <input
autoFocus type="email"
className="input input-primary input-bordered input-sm" id="email"
value={email} autoFocus
onChange={(event) => { className="input input-primary input-bordered input-sm"
setEmail(event.target.value); value={email}
}} onChange={(event) => {
/> setEmail(event.target.value);
</div> }}
<div className="flex flex-col space-y-1"> />
<div className="flex items-center justify-between"> </div>
<label htmlFor="password" className="text-sm"> <div className="flex flex-col space-y-1">
Password <div className="flex items-center justify-between">
</label> <label htmlFor="password" className="text-sm">
<button Password
onClick={() => { </label>
setForgot(true); <button
}} onClick={() => {
className="text-sm text-blue-600 hover:underline focus:text-blue-800" setForgot(true);
> }}
Forgot Password? className="text-sm text-blue-600 hover:underline focus:text-blue-800">
</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="w-full btn btn-primary"
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 className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border rounded-md focus:outline-none border-base-200 group hover:bg-base-300"
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(); signIn({ provider: 'google' });
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">
</div> Gmail
<span className="text-sm font-medium text-base-content">Gmail</span> </span>
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</> </>
)} )}
{forgot && ( {forgot && (
<> <>
<h3 className="my-4 text-2xl font-semibold">Password recovery</h3> <h3 className="my-4 text-2xl font-semibold">Password recovery</h3>
<form action="#" className="flex flex-col space-y-5"> <form action="#" className="flex flex-col space-y-5">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<label htmlFor="email" className="text-sm font-semibold text-gray-500"> <label
Email address htmlFor="email"
</label> className="text-sm font-semibold text-gray-500">
<input Email address
type="email" </label>
id="email" <input
autoFocus type="email"
className="input input-primary input-bordered input-sm" id="email"
value={email} autoFocus
onChange={(event) => { className="input input-primary input-bordered input-sm"
setEmail(event.target.value); value={email}
}} onChange={(event) => {
/> setEmail(event.target.value);
</div> }}
/>
</div>
<div> <div>
<button <button
className="btn btn-primary w-full btn-sm" className="w-full btn btn-primary btn-sm"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
resetPasswordLogin(); resetPasswordLogin();
}} }}>
> 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
> </button>
Go back to sign in </form>
</button> </>
</form> )}
</> </div>
)} );
</div>
);
}; };
export default Login; export default Login;

View File

@@ -4,100 +4,110 @@ import { toast } from 'react-toastify';
import { useState } from 'react'; import { useState } from 'react';
type SignUpPanelProps = { type SignUpPanelProps = {
signIn: ({}) => Promise<{ data: Record<string, unknown>; error: { message: string } }>; signIn: ({}) => Promise<{
signUp: ({}) => Promise<{ data: Record<string, unknown>; error: { message: string } }>; data: Record<string, unknown>;
error: { message: string };
}>;
signUp: ({}) => Promise<{
data: Record<string, unknown>;
error: { message: string };
}>;
}; };
const SignUpPanel = ({ signIn, signUp }: SignUpPanelProps): JSX.Element => { const SignUpPanel = ({ signIn, signUp }: SignUpPanelProps): JSX.Element => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const signup = (e: React.SyntheticEvent<HTMLButtonElement>) => { const signup = (e: React.SyntheticEvent<HTMLButtonElement>) => {
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.
signUp({ signUp({
email, email,
password, password,
}).then((result) => { }).then((result) => {
console.log(result); console.log(result);
if (result.error) { if (result.error) {
toast.error(result.error.message); toast.error(result.error.message);
} else if (result.data?.confirmation_sent_at) { } else if (result.data?.confirmation_sent_at) {
console.log(result.data.confirmation_sent_at); console.log(result.data.confirmation_sent_at);
toast.success('A confirmation email has been sent to you, watch your mailbox!'); toast.success(
} else if (result.data) { 'A confirmation email has been sent to you, watch your mailbox!'
router.push('/'); );
} } else if (result.data) {
}); router.push('/');
}; }
});
};
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="max-w-sm p-10 rounded-md shadow-md md:flex-1 bg-base-100 text-base-content font-body">
<h3 className="my-4 text-2xl font-semibold font-title">Account Sign Up</h3> <h3 className="my-4 text-2xl font-semibold font-title">
<form action="#" className="flex flex-col space-y-5"> Account Sign Up
<div className="flex flex-col space-y-1"> </h3>
<label htmlFor="email" className="text-sm"> <form action="#" className="flex flex-col space-y-5">
Email address <div className="flex flex-col space-y-1">
</label> <label htmlFor="email" className="text-sm">
<input Email address
type="email" </label>
id="email" <input
autoFocus type="email"
className="input input-primary input-bordered input-sm" id="email"
value={email} autoFocus
onChange={(event) => { className="input input-primary input-bordered input-sm"
setEmail(event.target.value); value={email}
}} onChange={(event) => {
/> setEmail(event.target.value);
</div> }}
<div className="flex flex-col space-y-1"> />
<input </div>
type="password" <div className="flex flex-col space-y-1">
id="password" <input
className="input input-primary input-bordered input-sm" type="password"
value={password} id="password"
onChange={(event) => { className="input input-primary input-bordered input-sm"
setPassword(event.target.value); value={password}
}} onChange={(event) => {
/> setPassword(event.target.value);
</div> }}
/>
</div>
<div> <div>
<button <button
id="loginBtn" id="loginBtn"
className="btn btn-primary w-full" className="w-full btn btn-primary"
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">or sign up with</span>
<span className="font-normal text-gray-500">or sign up 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 className="flex items-center justify-center px-4 py-2 space-x-2 transition-colors duration-300 border rounded-md focus:outline-none border-base-200 group hover:bg-base-300"
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(); signIn({ provider: 'google' });
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">
</div> Gmail
<span className="text-sm font-medium text-base-content">Gmail</span> </span>
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
); );
}; };
export default SignUpPanel; export default SignUpPanel;

View File

@@ -7,28 +7,30 @@ 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 = (): JSX.Element => { const ThemeToggle = (): JSX.Element => {
const [activeTheme, setActiveTheme] = useState(document.body.dataset.theme || ''); const [activeTheme, setActiveTheme] = useState(
const inactiveTheme = activeTheme === 'supaTheme' ? 'dark' : 'supaTheme'; document.body.dataset.theme || ''
);
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

@@ -10,25 +10,25 @@ setup more elements, visit their Github page https://github.com/garmeeh/next-seo
*/ */
function MyApp({ Component, pageProps }: AppProps): JSX.Element { function MyApp({ Component, pageProps }: AppProps): JSX.Element {
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,22 +1,24 @@
import Document, { import Document, {
DocumentContext, DocumentContext,
DocumentInitialProps, DocumentInitialProps,
Head, Head,
Html, Html,
Main, Main,
NextScript, NextScript,
} from 'next/document'; } from 'next/document';
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> { static async getInitialProps(
const initialProps = await Document.getInitialProps(ctx); ctx: DocumentContext
): Promise<DocumentInitialProps> {
const initialProps = await Document.getInitialProps(ctx);
return initialProps; return initialProps;
} }
render(): JSX.Element { render(): JSX.Element {
// 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')
@@ -27,17 +29,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

@@ -7,6 +7,9 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from 'utils/supabaseClient'; import { supabase } from 'utils/supabaseClient';
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(
supabase.auth.api.setAuthCookie(req, res); req: NextApiRequest,
res: NextApiResponse
): Promise<void> {
supabase.auth.api.setAuthCookie(req, res);
} }

View File

@@ -3,19 +3,22 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from 'utils/supabaseClient'; 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: NextApiRequest, res: NextApiResponse): Promise<void> => { const getUser = async (
const token = req.headers.token; req: NextApiRequest,
res: NextApiResponse
): Promise<void> => {
const token = req.headers.token;
if (typeof token !== 'string') { if (typeof token !== 'string') {
return res.status(401).json({ error: 'Missing auth token.' }); return res.status(401).json({ error: 'Missing auth token.' });
} }
if (token) { if (token) {
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

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

View File

@@ -2,34 +2,40 @@
This is a simple contact form for SupaNexTail This is a simple contact form for SupaNexTail
Using Sendgrid. Using Sendgrid.
*/ */
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import sgMail from '@sendgrid/mail'; import sgMail from '@sendgrid/mail';
const sendGrid = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => { const sendGrid = async (
if (req.method === 'POST') { req: NextApiRequest,
sgMail.setApiKey(process.env.SENDGRID_SECRET || ''); res: NextApiResponse
): Promise<void> => {
if (req.method === 'POST') {
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.status(200).send({ message: 'Your email has been sent', success: true }); res
}) .status(200)
.catch((error) => { .send({ message: 'Your email has been sent', success: true });
console.error(error); })
res.status(500).send({ .catch((error) => {
message: 'There was an issue with your email... please retry', console.error(error);
error, res.status(500).send({
}); message: 'There was an issue with your email... please retry',
}); error,
} });
});
}
}; };
export default sendGrid; export default sendGrid;

View File

@@ -6,85 +6,88 @@ import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit'; import rateLimit from '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 = new Stripe(process.env.STRIPE_SECRET || '', { const stripe = new Stripe(process.env.STRIPE_SECRET || '', {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
}); });
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(
await cors(req, res); req: NextApiRequest,
await limiter(req, res); res: NextApiResponse
if (req.method === 'POST') { ): Promise<void> {
const { priceId } = req.body; await cors(req, res);
await limiter(req, res);
if (req.method === 'POST') {
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: { metadata: {
priceId: req.body.priceId, priceId: req.body.priceId,
}, },
customer: req.body.customerId, customer: req.body.customerId,
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
// For metered billing, do not pass quantity // For metered billing, do not pass quantity
quantity: 1, quantity: 1,
}, },
], ],
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
// the actual Session ID is returned in the query parameter when your customer // the actual Session ID is returned in the query parameter when your customer
// is redirected to the success page. // is redirected to the success page.
success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/pricing`, cancel_url: `${req.headers.origin}/pricing`,
}) })
: await stripe.checkout.sessions.create({ : await stripe.checkout.sessions.create({
mode: 'subscription', mode: 'subscription',
payment_method_types: ['card'], payment_method_types: ['card'],
customer_email: req.body.email, customer_email: req.body.email,
client_reference_id: req.body.userId, client_reference_id: req.body.userId,
metadata: { metadata: {
priceId: req.body.priceId, priceId: req.body.priceId,
}, },
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
// For metered billing, do not pass quantity // For metered billing, do not pass quantity
quantity: 1, quantity: 1,
}, },
], ],
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
// the actual Session ID is returned in the query parameter when your customer // the actual Session ID is returned in the query parameter when your customer
// is redirected to the success page. // is redirected to the success page.
success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/pricing`, cancel_url: `${req.headers.origin}/pricing`,
}); });
res.status(200).send({ url: session.url }); res.status(200).send({ url: session.url });
} catch (e: unknown) { } catch (e: unknown) {
res.status(400); res.status(400);
if (e instanceof Error) { if (e instanceof Error) {
return res.send({ return res.send({
error: { error: {
message: e.message, message: e.message,
}, },
}); });
} }
} }
} }
} }

View File

@@ -1,5 +1,6 @@
/* 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 type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import Cors from 'cors'; import Cors from 'cors';
@@ -8,33 +9,36 @@ import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit'; import rateLimit from '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 = new Stripe(process.env.STRIPE_SECRET || '', { const stripe = new Stripe(process.env.STRIPE_SECRET || '', {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
}); });
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(
await cors(req, res); req: NextApiRequest,
await limiter(req, res); res: NextApiResponse
if (req.method === 'POST') { ): Promise<void> {
const returnUrl = `${req.headers.origin}/dashboard`; // Stripe will return to the dashboard, you can change it await cors(req, res);
await limiter(req, res);
if (req.method === 'POST') {
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

@@ -16,138 +16,147 @@ import initMiddleware from 'utils/init-middleware';
import rateLimit from 'express-rate-limit'; import rateLimit from '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.NEXT_PUBLIC_SUPABASE_URL || '',
process.env.SUPABASE_ADMIN_KEY || '' 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 = new Stripe(process.env.STRIPE_SECRET || '', { const stripe = new Stripe(process.env.STRIPE_SECRET || '', {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
maxNetworkRetries: 2, maxNetworkRetries: 2,
}); });
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> { export default async function handler(
await cors(req, res); req: NextApiRequest,
await limiter(req, res); res: NextApiResponse
): Promise<void> {
await cors(req, res);
await limiter(req, res);
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: Stripe.Event; let event: Stripe.Event;
const buf = await buffer(req); const buf = await buffer(req);
const sig = req.headers['stripe-signature'] as string; const sig = req.headers['stripe-signature'] as string;
try { try {
event = stripe.webhooks.constructEvent(buf, sig, process.env.STRIPE_WEBHOOK || ''); event = stripe.webhooks.constructEvent(
} catch (err) { buf,
console.log(err); sig,
console.log(`⚠️ Webhook signature verification failed.`); process.env.STRIPE_WEBHOOK || ''
console.log(`⚠️ Check the env file and enter the correct webhook secret.`); );
return res.send(400); } catch (err) {
} console.log(err);
// Extract the object from the event. console.log(`⚠️ Webhook signature verification failed.`);
const dataObject = event.data.object as { console.log(
client_reference_id: string; `⚠️ Check the env file and enter the correct webhook secret.`
customer: string; );
metadata: { return res.send(400);
priceId: string; }
}; // Extract the object from the event.
subscription: string; const dataObject = event.data.object as {
}; client_reference_id: string;
customer: string;
metadata: {
priceId: string;
};
subscription: string;
};
// 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 } = await supabase const { data: subscriptions } = await supabase
.from('subscriptions') .from('subscriptions')
.select('*') .select('*')
.eq('id', dataObject.client_reference_id); .eq('id', dataObject.client_reference_id);
if (subscriptions?.length == 0) { if (subscriptions?.length == 0) {
await supabase 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);
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()
.then(null, (err) => console.log('err: ', err)); // catch .then(null, (err) => console.log('err: ', err)); // catch
} else if (subscriptions?.length && subscriptions?.length > 0) { } else if (subscriptions?.length && 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()
.then(null, (err) => console.log('err: ', err)); // catch .then(null, (err) => console.log('err: ', err)); // catch
} }
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()
.then(null, (err) => console.log('err: ', err)); // catch .then(null, (err) => console.log('err: ', err)); // catch
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 = (): JSX.Element => ( const ContactPage = (): JSX.Element => (
<> <>
<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

@@ -10,114 +10,116 @@ import { supabase } from '../utils/supabaseClient';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
const DashboardPage = ({ const DashboardPage = ({
user, user,
profile, profile,
planName, planName,
}: { }: {
user: { user: {
id: string; id: string;
}; };
profile: { profile: {
username: string; username: string;
website: string; website: string;
avatar_url: string; avatar_url: string;
}; };
planName: string; planName: string;
}): JSX.Element => { }): JSX.Element => {
const [session, setSession] = useState(supabase.auth.session()); const [session, setSession] = useState(supabase.auth.session());
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]); }, [router, 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>
) : ( ) : (
<> <>
{session && ( {session && (
<Dashboard <Dashboard
key={user.id || undefined} key={user.id || undefined}
session={session} session={session}
profile={profile} profile={profile}
planName={planName} planName={planName}
/> />
)} )}
</> </>
)} )}
</Layout> </Layout>
</div> </div>
); );
}; };
export async function getServerSideProps(context: NextPageContext) { export async function getServerSideProps(context: NextPageContext) {
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(context.req); const { user } = await supabaseAdmin.auth.api.getUserByCookie(context.req);
const stripe = new Stripe(process.env.STRIPE_SECRET || '', { const stripe = new Stripe(process.env.STRIPE_SECRET || '', {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
maxNetworkRetries: 2, maxNetworkRetries: 2,
}); });
// 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 } = await supabaseAdmin const { data: plan } = await supabaseAdmin
.from('subscriptions') .from('subscriptions')
.select('subscription, paid_user') .select('subscription, paid_user')
.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?.subscription const subscription = plan?.subscription
? await stripe.subscriptions.retrieve(plan.subscription) ? await stripe.subscriptions.retrieve(plan.subscription)
: null; : null;
const { data: profile } = await supabaseAdmin const { data: profile } = 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?.items.data[0].price.id ? subscription?.items.data[0].price.id : null, plan: subscription?.items.data[0].price.id
profile, ? subscription?.items.data[0].price.id
// Retrieve the name of the subscription plan (Don't forget to add nickname to your prices) : null,
planName: plan?.paid_user profile,
? subscription?.items.data[0].plan.nickname // Retrieve the name of the subscription plan (Don't forget to add nickname to your prices)
? subscription?.items.data[0].plan.nickname planName: plan?.paid_user
: '[DEV] Please add a description for your prices' ? subscription?.items.data[0].plan.nickname
: 'Free Tier', ? subscription?.items.data[0].plan.nickname
}, : '[DEV] Please add a description for your prices'
}; : 'Free Tier',
} },
};
}
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.
return null; return null;
} }
export default DashboardPage; export default DashboardPage;

View File

@@ -1,5 +1,5 @@
@import "tailwindcss/tailwind.css"; @import 'tailwindcss/tailwind.css';
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Poppins:wght@400;600;700;800&display=swap"); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Poppins:wght@400;600;700;800&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -10,27 +10,27 @@ You can setup basic rules for your headers/text here
@layer base { @layer base {
h1 { h1 {
@apply text-5xl mb-5 font-bold font-title; @apply mb-5 text-5xl font-bold font-title;
} }
h2 { h2 {
@apply text-2xl mt-2 mb-3 font-title; @apply mt-2 mb-3 text-2xl font-title;
} }
p { p {
@apply text-left mb-5 font-body text-sm text-base-200 leading-loose; @apply mb-5 text-sm leading-loose text-left font-body text-base-200;
} }
ul { ul {
@apply list-disc ml-10 mb-3 font-body text-sm text-base-200 leading-loose; @apply mb-3 ml-10 text-sm leading-loose list-disc font-body text-base-200;
} }
} }
.nav-btn { .nav-btn {
position: relative; position: relative;
text-decoration: none; text-decoration: none;
@apply text-base-200 my-auto; @apply my-auto text-base-200;
} }
.nav-btn::before { .nav-btn::before {
content: ""; content: '';
position: absolute; position: absolute;
display: block; display: block;
width: 100%; width: 100%;
@@ -47,5 +47,5 @@ You can setup basic rules for your headers/text here
} }
.btn { .btn {
@apply font-normal @apply font-normal;
} }

View File

@@ -8,38 +8,50 @@ import Landing from 'components/Landing';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
const Home = (): JSX.Element => ( const Home = (): JSX.Element => (
<> <>
<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 property="og:title" content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} /> <meta
<meta property="og:title"
property="og:description" content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`}
content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS" />
/> <meta
<meta property="og:image" content="https://supanextail.dev/ogimage.png" /> property="og:description"
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 property="twitter:url" content="https://supanextail.dev/ogimage.png" /> <meta
<meta name="twitter:title" content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`} /> property="twitter:url"
<meta content="https://supanextail.dev/ogimage.png"
name="twitter:description" />
content="SupaNexTail is a boilerplate for your SaaS, based on Next.js, Supabase, and TailwindCSS" <meta
/> name="twitter:title"
<meta name="twitter:image" content="https://supanextail.dev/ogimage.png" /> content={`Welcome to ${process.env.NEXT_PUBLIC_TITLE} 👋`}
<meta charSet="UTF-8" /> />
</Head> <meta
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,21 +10,21 @@ import { NextSeo } from 'next-seo';
import { useAuth } from 'utils/AuthContext'; import { useAuth } from 'utils/AuthContext';
const LoginPage = (): JSX.Element => { const LoginPage = (): JSX.Element => {
const { signIn, resetPassword } = useAuth(); const { signIn, 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 w-full mt-20 justify-evenly">
<Login signIn={signIn} resetPassword={resetPassword} /> <Login signIn={signIn} resetPassword={resetPassword} />
</div> </div>
</Layout> </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 = (): JSX.Element => ( const PricingPage = (): JSX.Element => (
<> <>
<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 = (): JSX.Element => ( const PrivacyPage = (): JSX.Element => (
<> <>
<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 = (): JSX.Element => ( const SignUpPage = (): JSX.Element => (
<> <>
<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 w-full mt-20 justify-evenly">
<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 = (): JSX.Element => ( const TermsPage = (): JSX.Element => (
<> <>
<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,13 +1,13 @@
import { test } from '@playwright/test'; import { test } from '@playwright/test';
test('basic test', async ({ page }) => { test('basic test', async ({ page }) => {
const myURL: string = process.env.PLAYWRIGHT_TEST_BASE_URL const myURL: string = process.env.PLAYWRIGHT_TEST_BASE_URL
? process.env.PLAYWRIGHT_TEST_BASE_URL ? process.env.PLAYWRIGHT_TEST_BASE_URL
: ('http://localhost:3000/' as string); : ('http://localhost:3000/' as string);
await page.goto(myURL); await page.goto(myURL);
// Go through all pages // Go through all pages
await page.click('#login'); await page.click('#login');
await page.click('#pricing'); await page.click('#pricing');
await page.click('#signup'); await page.click('#signup');
}); });

View File

@@ -3,28 +3,28 @@
import { PlaywrightTestConfig } from '@playwright/test'; import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
projects: [ projects: [
{ {
name: 'Chrome Stable', name: 'Chrome Stable',
use: { use: {
baseURL: process.env.NEXT_PUBLIC_VERCEL_URL baseURL: process.env.NEXT_PUBLIC_VERCEL_URL
? process.env.NEXT_PUBLIC_VERCEL_URL ? process.env.NEXT_PUBLIC_VERCEL_URL
: 'http://localhost:3000', : 'http://localhost:3000',
browserName: 'chromium', browserName: 'chromium',
// Test against Chrome Stable channel. // Test against Chrome Stable channel.
channel: 'chrome', channel: 'chrome',
}, },
}, },
{ {
name: 'Desktop Safari', name: 'Desktop Safari',
use: { use: {
baseURL: process.env.NEXT_PUBLIC_VERCEL_URL baseURL: process.env.NEXT_PUBLIC_VERCEL_URL
? process.env.NEXT_PUBLIC_VERCEL_URL ? process.env.NEXT_PUBLIC_VERCEL_URL
: 'http://localhost:3000', : 'http://localhost:3000',
browserName: 'webkit', browserName: 'webkit',
viewport: { width: 1200, height: 750 }, viewport: { width: 1200, height: 750 },
}, },
}, },
], ],
}; };
export default config; export default config;

File diff suppressed because one or more lines are too long

View File

@@ -6,51 +6,57 @@ 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 [session, setSession] = useState(); const [session, setSession] = 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);
setSession(session ?? null); setSession(session ?? 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(async (event, session) => { const { data: listener } = supabase.auth.onAuthStateChange(
if ((event === 'SIGNED_OUT') | (event === 'SIGNED_IN')) { async (event, session) => {
fetch('/api/auth', { if ((event === 'SIGNED_OUT') | (event === 'SIGNED_IN')) {
method: 'POST', fetch('/api/auth', {
headers: new Headers({ 'Content-Type': 'application/json' }), method: 'POST',
credentials: 'same-origin', headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ event, session }), credentials: 'same-origin',
}).then((res) => res.json()); body: JSON.stringify({ event, session }),
} }).then((res) => res.json());
if (event === 'USER_UPDATED') { }
} if (event === 'USER_UPDATED') {
setUser(session?.user ?? null); }
setSession(session ?? null); setUser(session?.user ?? null);
setLoading(false); setSession(session ?? null);
}); 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,
session, session,
}; };
return <AuthContext.Provider value={value}>{!loading && children}</AuthContext.Provider>; return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}; };
// export the useAuth hook // export the useAuth hook

View File

@@ -4,13 +4,13 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
export default function initMiddleware(middleware: any) { export default function initMiddleware(middleware: any) {
return (req: NextApiRequest, res: NextApiResponse) => return (req: NextApiRequest, res: NextApiResponse) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
middleware(req, res, (result: any) => { middleware(req, res, (result: any) => {
if (result instanceof Error) { if (result instanceof Error) {
return reject(result); return reject(result);
} }
return resolve(result); return resolve(result);
}); });
}); });
} }

View File

@@ -1,14 +1,15 @@
/** /**
* This is a singleton to ensure we only instantiate Stripe once. * This is a singleton to ensure we only instantiate Stripe once.
*/ */
import { Stripe, loadStripe } from '@stripe/stripe-js'; import { Stripe, loadStripe } from '@stripe/stripe-js';
let stripePromise: Promise<Stripe | null>; let stripePromise: Promise<Stripe | null>;
const getStripe = (): Promise<Stripe | null> => { const getStripe = (): Promise<Stripe | null> => {
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 } = await supabase const { data: subscriptions } = await supabase
.from('subscriptions') .from('subscriptions')
.select('paid_user, plan') .select('paid_user, plan')
.single(); .single();
if (subscriptions) { if (subscriptions) {
return subscriptions; return subscriptions;
} }
}; };