feat: user activation and emails
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
# Created by Vercel CLI
|
|
||||||
NX_DAEMON=""
|
NX_DAEMON=""
|
||||||
POSTGRES_DATABASE=""
|
POSTGRES_DATABASE=""
|
||||||
POSTGRES_HOST=""
|
POSTGRES_HOST=""
|
||||||
@@ -23,4 +22,8 @@ VERCEL_GIT_REPO_ID=""
|
|||||||
VERCEL_GIT_REPO_OWNER=""
|
VERCEL_GIT_REPO_OWNER=""
|
||||||
VERCEL_GIT_REPO_SLUG=""
|
VERCEL_GIT_REPO_SLUG=""
|
||||||
VERCEL_URL=""
|
VERCEL_URL=""
|
||||||
NEWS_LIMIT=""
|
NEWS_LIMIT=""
|
||||||
|
RESEND_KEY=""
|
||||||
|
RESEND_FROM=""
|
||||||
|
SECRET_HASH=""
|
||||||
|
HOME_URL=""
|
||||||
13
README.md
13
README.md
@@ -1,14 +1,11 @@
|
|||||||
|
# Hackernews newsletter
|
||||||
|
|
||||||
## To do
|
## To do
|
||||||
|
|
||||||
Basics:
|
- A proper UI
|
||||||
|
|
||||||
- Email templates
|
- Email templates
|
||||||
- Subscribe email
|
- Captcha?
|
||||||
- Unsubscribe email
|
- Tests
|
||||||
- Newsletter email cron job
|
|
||||||
- Cookiebot
|
|
||||||
- Google Analytics
|
|
||||||
- SEO
|
|
||||||
|
|
||||||
## Vercel basics
|
## Vercel basics
|
||||||
|
|
||||||
|
|||||||
42
app/api/confirmation/route.ts
Normal file
42
app/api/confirmation/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../../../prisma/prisma';
|
||||||
|
import { ApiResponse } from '../../../utils/apiResponse';
|
||||||
|
import { ConfirmationSchema, ResponseSchema } from '../../../utils/types';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json();
|
||||||
|
const validation = ConfirmationSchema.safeParse(body);
|
||||||
|
if (!validation.success || !validation.data.code) {
|
||||||
|
return ApiResponse(400, 'Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
code: validation.data.code
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
code: validation.data.code
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
confirmed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const message: z.infer<typeof ResponseSchema> = {
|
||||||
|
message: `Thank you for confirming the subscripion!`
|
||||||
|
};
|
||||||
|
|
||||||
|
return ApiResponse(200, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: z.infer<typeof ResponseSchema> = {
|
||||||
|
message: `Nothing to see here...`
|
||||||
|
};
|
||||||
|
|
||||||
|
return ApiResponse(200, JSON.stringify(message));
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import NewsletterEmail from '../../../components/emails/newsletter';
|
||||||
import prisma from '../../../prisma/prisma';
|
import prisma from '../../../prisma/prisma';
|
||||||
import { NewsSchema } from '../../utils/types';
|
import { sendEmail } from '../../../utils/sender';
|
||||||
import { singleNews, topNews } from '../../utils/urls';
|
import { NewsSchema } from '../../../utils/types';
|
||||||
|
import { singleNews, topNews } from '../../../utils/urls';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
if (
|
if (
|
||||||
@@ -11,47 +13,22 @@ export async function GET(request: Request) {
|
|||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await hackernewsApi();
|
|
||||||
|
|
||||||
return new NextResponse(JSON.stringify(response), {
|
|
||||||
status: 200
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hackernewsApi() {
|
|
||||||
const topstories: number[] = await fetch(topNews).then(res => res.json());
|
const topstories: number[] = await fetch(topNews).then(res => res.json());
|
||||||
|
|
||||||
console.log('topstories', topstories);
|
|
||||||
|
|
||||||
const newsPromises = topstories
|
const newsPromises = topstories
|
||||||
.splice(0, Number(process.env.NEWS_LIMIT))
|
.splice(0, Number(process.env.NEWS_LIMIT))
|
||||||
.map(async id => {
|
.map(async id => {
|
||||||
console.log('id', id);
|
|
||||||
const sourceNews: z.infer<typeof NewsSchema> = await fetch(
|
const sourceNews: z.infer<typeof NewsSchema> = await fetch(
|
||||||
singleNews(id)
|
singleNews(id)
|
||||||
).then(res => res.json());
|
).then(res => res.json());
|
||||||
|
|
||||||
console.log('sourceNews', sourceNews);
|
|
||||||
|
|
||||||
return await prisma.news.upsert({
|
return await prisma.news.upsert({
|
||||||
create: {
|
create: {
|
||||||
id,
|
...sourceNews,
|
||||||
title: sourceNews.title,
|
id
|
||||||
text: sourceNews.text,
|
|
||||||
type: sourceNews.type,
|
|
||||||
by: sourceNews.by,
|
|
||||||
time: sourceNews.time,
|
|
||||||
url: sourceNews.url,
|
|
||||||
score: sourceNews.score
|
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
title: sourceNews.title,
|
...sourceNews
|
||||||
text: sourceNews.text,
|
|
||||||
type: sourceNews.type,
|
|
||||||
by: sourceNews.by,
|
|
||||||
time: sourceNews.time,
|
|
||||||
url: sourceNews.url,
|
|
||||||
score: sourceNews.score
|
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
id
|
id
|
||||||
@@ -64,5 +41,30 @@ async function hackernewsApi() {
|
|||||||
|
|
||||||
const newsIds = await Promise.all(newsPromises);
|
const newsIds = await Promise.all(newsPromises);
|
||||||
|
|
||||||
return newsIds.map(news => news.id);
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
email: 'riccardo.s@hey.com',
|
||||||
|
confirmed: true,
|
||||||
|
deleted: false
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!users) {
|
||||||
|
return new NextResponse('No users.', {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
users.map(user => user.email),
|
||||||
|
`What's new from Hackernews?`,
|
||||||
|
NewsletterEmail(newsIds.map(news => news.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return new NextResponse(`Newsletter sent to ${users.length} addresses.`, {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,70 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { fromZodError } from 'zod-validation-error';
|
import SubscribeEmail from '../../../components/emails/subscribe';
|
||||||
import prisma from '../../../prisma/prisma';
|
import prisma from '../../../prisma/prisma';
|
||||||
import { ResponseSchema, SubscribeFormSchema } from '../../utils/types';
|
import { ApiResponse } from '../../../utils/apiResponse';
|
||||||
|
import { sendEmail } from '../../../utils/sender';
|
||||||
|
import { ResponseSchema, SubscribeFormSchema } from '../../../utils/types';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const validation = SubscribeFormSchema.safeParse(body);
|
const validation = SubscribeFormSchema.safeParse(body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
const message = fromZodError(validation.error);
|
return ApiResponse(400, 'Bad request');
|
||||||
return new Response(message.message, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, targetingAllowed } = validation.data;
|
const { email } = validation.data;
|
||||||
|
|
||||||
|
const userAlreadyConfirmed = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
confirmed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userAlreadyConfirmed) {
|
||||||
|
if (userAlreadyConfirmed.deleted) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
email
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deleted: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: z.infer<typeof ResponseSchema> = {
|
||||||
|
message: `Thank you for subscribing!`
|
||||||
|
};
|
||||||
|
|
||||||
|
return ApiResponse(200, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${process.env.SECRET_HASH}${email}}`)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
create: {
|
create: {
|
||||||
email,
|
email,
|
||||||
targetingAllowed
|
code
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
targetingAllowed
|
code
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
email
|
email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendEmail([email], 'Welcome!', SubscribeEmail(code));
|
||||||
|
|
||||||
const message: z.infer<typeof ResponseSchema> = {
|
const message: z.infer<typeof ResponseSchema> = {
|
||||||
message: `${email} subscribed!`
|
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.`
|
||||||
};
|
};
|
||||||
return new Response(JSON.stringify(message), { status: 200 });
|
|
||||||
|
return ApiResponse(200, JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { fromZodError } from 'zod-validation-error';
|
import UnsubscribeEmail from '../../../components/emails/unsubscribe';
|
||||||
import prisma from '../../../prisma/prisma';
|
import prisma from '../../../prisma/prisma';
|
||||||
import { ResponseSchema, UnsubscribeFormSchema } from '../../utils/types';
|
import { ApiResponse } from '../../../utils/apiResponse';
|
||||||
|
import { sendEmail } from '../../../utils/sender';
|
||||||
|
import { ResponseSchema, UnsubscribeFormSchema } from '../../../utils/types';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const validation = UnsubscribeFormSchema.safeParse(body);
|
const validation = UnsubscribeFormSchema.safeParse(body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
const message = fromZodError(validation.error);
|
return ApiResponse(400, 'Bad request');
|
||||||
return new Response(message.message, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email } = validation.data;
|
const { email } = validation.data;
|
||||||
|
|
||||||
try {
|
const user = await prisma.user.findUnique({
|
||||||
await prisma.user.delete({
|
where: {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && !user.deleted) {
|
||||||
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
email
|
email
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deleted: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
sendEmail([email], 'Unsubscribe confirmation', UnsubscribeEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: z.infer<typeof ResponseSchema> = {
|
const message: z.infer<typeof ResponseSchema> = {
|
||||||
message: `${email} unsubscribe!`
|
message: `${email} unsubscribed!`
|
||||||
};
|
};
|
||||||
return new Response(JSON.stringify(message), { status: 200 });
|
|
||||||
|
return ApiResponse(200, JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/confirmation/page.tsx
Normal file
5
app/confirmation/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ConfirmationPage } from '../../components/pages/confirmation';
|
||||||
|
|
||||||
|
export default function Confirmation() {
|
||||||
|
return <ConfirmationPage />;
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ const inter = Inter({ subsets: ['latin'] });
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: 'Create Next App',
|
||||||
description: 'Generated by create next app'
|
description: 'Generated by create next app',
|
||||||
|
keywords: 'newsletter, hackernews, technology, coding, programming, news'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
20
app/manifest.ts
Normal file
20
app/manifest.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'Newsletter',
|
||||||
|
short_name: 'Newsletter',
|
||||||
|
description: 'Newsletter with Hackernews top stories',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#fff',
|
||||||
|
theme_color: '#fff',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/favicon.ico',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/x-icon'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
17
app/page.tsx
17
app/page.tsx
@@ -1,16 +1,11 @@
|
|||||||
'use client';
|
import { CustomLink } from '../components/elements/customLink';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { VerticalLayout } from '../components/VerticalLayout';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VerticalLayout>
|
<div>
|
||||||
<h1>Home</h1>
|
<CustomLink path='/subscribe' text='Subscribe' />
|
||||||
<Button label='Subscribe' onClick={() => router.push('/subscribe')} />
|
<CustomLink path='/unsubscribe' text='Unsubscribe' />
|
||||||
<Button label='Unsubscribe' onClick={() => router.push('/unsubscribe')} />
|
<CustomLink path='/privacy' text='Privacy' />
|
||||||
</VerticalLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
414
app/privacy/page.tsx
Normal file
414
app/privacy/page.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
export default function Privacy() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p>Last updated: December 03, 2023</p>
|
||||||
|
<p>
|
||||||
|
This Privacy Policy describes Our policies and procedures on the
|
||||||
|
collection, use and disclosure of Your information when You use the
|
||||||
|
Service and tells You about Your privacy rights and how the law protects
|
||||||
|
You.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We use Your Personal data to provide and improve the Service. By using
|
||||||
|
the Service, You agree to the collection and use of information in
|
||||||
|
accordance with this Privacy Policy. This Privacy Policy has been
|
||||||
|
created with the help of the{' '}
|
||||||
|
<a
|
||||||
|
href='https://www.termsfeed.com/privacy-policy-generator/'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
Privacy Policy Generator
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<h2>Interpretation and Definitions</h2>
|
||||||
|
<h3>Interpretation</h3>
|
||||||
|
<p>
|
||||||
|
The words of which the initial letter is capitalized have meanings
|
||||||
|
defined under the following conditions. The following definitions shall
|
||||||
|
have the same meaning regardless of whether they appear in singular or
|
||||||
|
in plural.
|
||||||
|
</p>
|
||||||
|
<h3>Definitions</h3>
|
||||||
|
<p>For the purposes of this Privacy Policy:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Account</strong> means a unique account created for You to
|
||||||
|
access our Service or parts of our Service.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Affiliate</strong> means an entity that controls, is
|
||||||
|
controlled by or is under common control with a party, where
|
||||||
|
"control" means ownership of 50% or more of the shares,
|
||||||
|
equity interest or other securities entitled to vote for election of
|
||||||
|
directors or other managing authority.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Application</strong> refers to FromPixels, the software
|
||||||
|
program provided by the Company.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Company</strong> (referred to as either "the
|
||||||
|
Company", "We", "Us" or "Our" in
|
||||||
|
this Agreement) refers to FromPixels.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Country</strong> refers to: Italy
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Device</strong> means any device that can access the Service
|
||||||
|
such as a computer, a cellphone or a digital tablet.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Personal Data</strong> is any information that relates to an
|
||||||
|
identified or identifiable individual.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Service</strong> refers to the Application.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Service Provider</strong> means any natural or legal person
|
||||||
|
who processes the data on behalf of the Company. It refers to
|
||||||
|
third-party companies or individuals employed by the Company to
|
||||||
|
facilitate the Service, to provide the Service on behalf of the
|
||||||
|
Company, to perform services related to the Service or to assist the
|
||||||
|
Company in analyzing how the Service is used.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>Usage Data</strong> refers to data collected automatically,
|
||||||
|
either generated by the use of the Service or from the Service
|
||||||
|
infrastructure itself (for example, the duration of a page visit).
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>You</strong> means the individual accessing or using the
|
||||||
|
Service, or the company, or other legal entity on behalf of which
|
||||||
|
such individual is accessing or using the Service, as applicable.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Collecting and Using Your Personal Data</h2>
|
||||||
|
<h3>Types of Data Collected</h3>
|
||||||
|
<h4>Personal Data</h4>
|
||||||
|
<p>
|
||||||
|
While using Our Service, We may ask You to provide Us with certain
|
||||||
|
personally identifiable information that can be used to contact or
|
||||||
|
identify You. Personally identifiable information may include, but is
|
||||||
|
not limited to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>Email address</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>Usage Data</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Usage Data</h4>
|
||||||
|
<p>Usage Data is collected automatically when using the Service.</p>
|
||||||
|
<p>
|
||||||
|
Usage Data may include information such as Your Device&aposs Internet
|
||||||
|
Protocol address (e.g. IP address), browser type, browser version, the
|
||||||
|
pages of our Service that You visit, the time and date of Your visit,
|
||||||
|
the time spent on those pages, unique device identifiers and other
|
||||||
|
diagnostic data.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When You access the Service by or through a mobile device, We may
|
||||||
|
collect certain information automatically, including, but not limited
|
||||||
|
to, the type of mobile device You use, Your mobile device unique ID, the
|
||||||
|
IP address of Your mobile device, Your mobile operating system, the type
|
||||||
|
of mobile Internet browser You use, unique device identifiers and other
|
||||||
|
diagnostic data.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We may also collect information that Your browser sends whenever You
|
||||||
|
visit our Service or when You access the Service by or through a mobile
|
||||||
|
device.
|
||||||
|
</p>
|
||||||
|
<h3>Use of Your Personal Data</h3>
|
||||||
|
<p>The Company may use Personal Data for the following purposes:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>To provide and maintain our Service</strong>, including to
|
||||||
|
monitor the usage of our Service.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>To manage Your Account:</strong> to manage Your registration
|
||||||
|
as a user of the Service. The Personal Data You provide can give You
|
||||||
|
access to different functionalities of the Service that are
|
||||||
|
available to You as a registered user.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>For the performance of a contract:</strong> the development,
|
||||||
|
compliance and undertaking of the purchase contract for the
|
||||||
|
products, items or services You have purchased or of any other
|
||||||
|
contract with Us through the Service.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>To contact You:</strong> To contact You by email, telephone
|
||||||
|
calls, SMS, or other equivalent forms of electronic communication,
|
||||||
|
such as a mobile application&aposs push notifications regarding
|
||||||
|
updates or informative communications related to the
|
||||||
|
functionalities, products or contracted services, including the
|
||||||
|
security updates, when necessary or reasonable for their
|
||||||
|
implementation.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>To provide You</strong> with news, special offers and
|
||||||
|
general information about other goods, services and events which we
|
||||||
|
offer that are similar to those that you have already purchased or
|
||||||
|
enquired about unless You have opted not to receive such
|
||||||
|
information.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>To manage Your requests:</strong> To attend and manage Your
|
||||||
|
requests to Us.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>For business transfers:</strong> We may use Your information
|
||||||
|
to evaluate or conduct a merger, divestiture, restructuring,
|
||||||
|
reorganization, dissolution, or other sale or transfer of some or
|
||||||
|
all of Our assets, whether as a going concern or as part of
|
||||||
|
bankruptcy, liquidation, or similar proceeding, in which Personal
|
||||||
|
Data held by Us about our Service users is among the assets
|
||||||
|
transferred.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>For other purposes</strong>: We may use Your information for
|
||||||
|
other purposes, such as data analysis, identifying usage trends,
|
||||||
|
determining the effectiveness of our promotional campaigns and to
|
||||||
|
evaluate and improve our Service, products, services, marketing and
|
||||||
|
your experience.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>We may share Your personal information in the following situations:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>With Service Providers:</strong> We may share Your personal
|
||||||
|
information with Service Providers to monitor and analyze the use of
|
||||||
|
our Service, to contact You.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>For business transfers:</strong> We may share or transfer Your
|
||||||
|
personal information in connection with, or during negotiations of,
|
||||||
|
any merger, sale of Company assets, financing, or acquisition of all
|
||||||
|
or a portion of Our business to another company.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>With Affiliates:</strong> We may share Your information with
|
||||||
|
Our affiliates, in which case we will require those affiliates to
|
||||||
|
honor this Privacy Policy. Affiliates include Our parent company and
|
||||||
|
any other subsidiaries, joint venture partners or other companies that
|
||||||
|
We control or that are under common control with Us.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>With business partners:</strong> We may share Your information
|
||||||
|
with Our business partners to offer You certain products, services or
|
||||||
|
promotions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>With other users:</strong> when You share personal information
|
||||||
|
or otherwise interact in the public areas with other users, such
|
||||||
|
information may be viewed by all users and may be publicly distributed
|
||||||
|
outside.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>With Your consent</strong>: We may disclose Your personal
|
||||||
|
information for any other purpose with Your consent.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Retention of Your Personal Data</h3>
|
||||||
|
<p>
|
||||||
|
The Company will retain Your Personal Data only for as long as is
|
||||||
|
necessary for the purposes set out in this Privacy Policy. We will
|
||||||
|
retain and use Your Personal Data to the extent necessary to comply with
|
||||||
|
our legal obligations (for example, if we are required to retain your
|
||||||
|
data to comply with applicable laws), resolve disputes, and enforce our
|
||||||
|
legal agreements and policies.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The Company will also retain Usage Data for internal analysis purposes.
|
||||||
|
Usage Data is generally retained for a shorter period of time, except
|
||||||
|
when this data is used to strengthen the security or to improve the
|
||||||
|
functionality of Our Service, or We are legally obligated to retain this
|
||||||
|
data for longer time periods.
|
||||||
|
</p>
|
||||||
|
<h3>Transfer of Your Personal Data</h3>
|
||||||
|
<p>
|
||||||
|
Your information, including Personal Data, is processed at the
|
||||||
|
Company&aposs operating offices and in any other places where the
|
||||||
|
parties involved in the processing are located. It means that this
|
||||||
|
information may be transferred to — and maintained on — computers
|
||||||
|
located outside of Your state, province, country or other governmental
|
||||||
|
jurisdiction where the data protection laws may differ than those from
|
||||||
|
Your jurisdiction.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your consent to this Privacy Policy followed by Your submission of such
|
||||||
|
information represents Your agreement to that transfer.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The Company will take all steps reasonably necessary to ensure that Your
|
||||||
|
data is treated securely and in accordance with this Privacy Policy and
|
||||||
|
no transfer of Your Personal Data will take place to an organization or
|
||||||
|
a country unless there are adequate controls in place including the
|
||||||
|
security of Your data and other personal information.
|
||||||
|
</p>
|
||||||
|
<h3>Delete Your Personal Data</h3>
|
||||||
|
<p>
|
||||||
|
You have the right to delete or request that We assist in deleting the
|
||||||
|
Personal Data that We have collected about You.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our Service may give You the ability to delete certain information about
|
||||||
|
You from within the Service.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You may update, amend, or delete Your information at any time by signing
|
||||||
|
in to Your Account, if you have one, and visiting the account settings
|
||||||
|
section that allows you to manage Your personal information. You may
|
||||||
|
also contact Us to request access to, correct, or delete any personal
|
||||||
|
information that You have provided to Us.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please note, however, that We may need to retain certain information
|
||||||
|
when we have a legal obligation or lawful basis to do so.
|
||||||
|
</p>
|
||||||
|
<h3>Disclosure of Your Personal Data</h3>
|
||||||
|
<h4>Business Transactions</h4>
|
||||||
|
<p>
|
||||||
|
If the Company is involved in a merger, acquisition or asset sale, Your
|
||||||
|
Personal Data may be transferred. We will provide notice before Your
|
||||||
|
Personal Data is transferred and becomes subject to a different Privacy
|
||||||
|
Policy.
|
||||||
|
</p>
|
||||||
|
<h4>Law enforcement</h4>
|
||||||
|
<p>
|
||||||
|
Under certain circumstances, the Company may be required to disclose
|
||||||
|
Your Personal Data if required to do so by law or in response to valid
|
||||||
|
requests by public authorities (e.g. a court or a government agency).
|
||||||
|
</p>
|
||||||
|
<h4>Other legal requirements</h4>
|
||||||
|
<p>
|
||||||
|
The Company may disclose Your Personal Data in the good faith belief
|
||||||
|
that such action is necessary to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Comply with a legal obligation</li>
|
||||||
|
<li>Protect and defend the rights or property of the Company</li>
|
||||||
|
<li>
|
||||||
|
Prevent or investigate possible wrongdoing in connection with the
|
||||||
|
Service
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Protect the personal safety of Users of the Service or the public
|
||||||
|
</li>
|
||||||
|
<li>Protect against legal liability</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Security of Your Personal Data</h3>
|
||||||
|
<p>
|
||||||
|
The security of Your Personal Data is important to Us, but remember that
|
||||||
|
no method of transmission over the Internet, or method of electronic
|
||||||
|
storage is 100% secure. While We strive to use commercially acceptable
|
||||||
|
means to protect Your Personal Data, We cannot guarantee its absolute
|
||||||
|
security.
|
||||||
|
</p>
|
||||||
|
<h2>Children&aposs Privacy</h2>
|
||||||
|
<p>
|
||||||
|
Our Service does not address anyone under the age of 13. We do not
|
||||||
|
knowingly collect personally identifiable information from anyone under
|
||||||
|
the age of 13. If You are a parent or guardian and You are aware that
|
||||||
|
Your child has provided Us with Personal Data, please contact Us. If We
|
||||||
|
become aware that We have collected Personal Data from anyone under the
|
||||||
|
age of 13 without verification of parental consent, We take steps to
|
||||||
|
remove that information from Our servers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If We need to rely on consent as a legal basis for processing Your
|
||||||
|
information and Your country requires consent from a parent, We may
|
||||||
|
require Your parent&aposs consent before We collect and use that
|
||||||
|
information.
|
||||||
|
</p>
|
||||||
|
<h2>Links to Other Websites</h2>
|
||||||
|
<p>
|
||||||
|
Our Service may contain links to other websites that are not operated by
|
||||||
|
Us. If You click on a third party link, You will be directed to that
|
||||||
|
third party&aposs site. We strongly advise You to review the Privacy
|
||||||
|
Policy of every site You visit.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We have no control over and assume no responsibility for the content,
|
||||||
|
privacy policies or practices of any third party sites or services.
|
||||||
|
</p>
|
||||||
|
<h2>Changes to this Privacy Policy</h2>
|
||||||
|
<p>
|
||||||
|
We may update Our Privacy Policy from time to time. We will notify You
|
||||||
|
of any changes by posting the new Privacy Policy on this page.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We will let You know via email and/or a prominent notice on Our Service,
|
||||||
|
prior to the change becoming effective and update the "Last
|
||||||
|
updated" date at the top of this Privacy Policy.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You are advised to review this Privacy Policy periodically for any
|
||||||
|
changes. Changes to this Privacy Policy are effective when they are
|
||||||
|
posted on this page.
|
||||||
|
</p>
|
||||||
|
<h2>Contact Us</h2>
|
||||||
|
<p>
|
||||||
|
If you have any questions about this Privacy Policy, You can contact us:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
By visiting this page on our website:{' '}
|
||||||
|
<a
|
||||||
|
href={`${process.env.HOME_URL}/privacy`}
|
||||||
|
rel='external nofollow noopener'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
{`${process.env.HOME_URL}/privacy`}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/robots.ts
Normal file
11
app/robots.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
disallow: '/'
|
||||||
|
},
|
||||||
|
sitemap: `${process.env.HOME_URL!}/sitemap.xml`
|
||||||
|
};
|
||||||
|
}
|
||||||
24
app/sitemap.ts
Normal file
24
app/sitemap.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: process.env.HOME_URL!,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'yearly',
|
||||||
|
priority: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${process.env.HOME_URL!}/subscribe`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'yearly',
|
||||||
|
priority: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${process.env.HOME_URL!}/unsubscribe`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'yearly',
|
||||||
|
priority: 0.5
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,81 +1,5 @@
|
|||||||
'use client';
|
import { SubscribeForm } from '../../components/pages/subscribe';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Button } from '../../components/Button';
|
|
||||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
|
||||||
import { ResponseSchema } from './../utils/types';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Subscribe() {
|
||||||
const router = useRouter();
|
return <SubscribeForm />;
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: data.get('email'),
|
|
||||||
targetingAllowed: isChecked
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response?.ok) {
|
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formResponse: z.infer<typeof ResponseSchema> =
|
|
||||||
await response.json();
|
|
||||||
|
|
||||||
console.log(formResponse);
|
|
||||||
|
|
||||||
router.push('/success');
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
alert('Error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCheckboxChange() {
|
|
||||||
setIsChecked(!isChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VerticalLayout>
|
|
||||||
<form className='container' onSubmit={handleSubmit}>
|
|
||||||
<h1>Subscribe to newsletter</h1>
|
|
||||||
<div className='email block'>
|
|
||||||
<label htmlFor='frm-email'>Email</label>
|
|
||||||
<input
|
|
||||||
placeholder='example@email.com'
|
|
||||||
id='email'
|
|
||||||
type='email'
|
|
||||||
name='email'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='checkbox block'>
|
|
||||||
<label htmlFor='frm-checkbox'>Allow advertising</label>
|
|
||||||
<input
|
|
||||||
id='targetingAllowed'
|
|
||||||
type='checkbox'
|
|
||||||
name='targetingAllowed'
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='button block'>
|
|
||||||
<button type='submit'>Subscribe</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<Button label='Home' onClick={() => router.push('/')} />
|
|
||||||
<Button label='Unsubscribe' onClick={() => router.push('/unsubscribe')} />
|
|
||||||
</VerticalLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '../../components/Button';
|
|
||||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VerticalLayout>
|
|
||||||
<h1>Success!</h1>
|
|
||||||
<Button label='Home' onClick={() => router.push('/')} />
|
|
||||||
</VerticalLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +1,5 @@
|
|||||||
'use client';
|
import { UnsubscribeForm } from '../../components/pages/unsubscribe';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import React from 'react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Button } from '../../components/Button';
|
|
||||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
|
||||||
import { ResponseSchema } from './../utils/types';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Unsubscribe() {
|
||||||
const router = useRouter();
|
return <UnsubscribeForm />;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/unsubscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: data.get('email')
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response?.ok) {
|
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formResponse: z.infer<typeof ResponseSchema> =
|
|
||||||
await response.json();
|
|
||||||
|
|
||||||
console.log(formResponse);
|
|
||||||
|
|
||||||
router.push('/success');
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
alert('Error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VerticalLayout>
|
|
||||||
<form className='container' onSubmit={handleSubmit}>
|
|
||||||
<h1>Unsubscribe newsletter</h1>
|
|
||||||
<div className='email block'>
|
|
||||||
<label htmlFor='frm-email'>Email</label>
|
|
||||||
<input
|
|
||||||
placeholder='example@email.com'
|
|
||||||
id='email'
|
|
||||||
type='email'
|
|
||||||
name='email'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='button block'>
|
|
||||||
<button type='submit'>Unsubscribe</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<Button label='Home' onClick={() => router.push('/')} />
|
|
||||||
<Button label='Subscribe' onClick={() => router.push('/subscribe')} />
|
|
||||||
</VerticalLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
type ButtonProps = {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = ({ label, onClick }: ButtonProps) => (
|
|
||||||
<button onClick={onClick} key={1} className="overflow-hidden rounded-md">
|
|
||||||
<h1>{label}</h1>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const VerticalLayout = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
14
components/elements/customLink.tsx
Normal file
14
components/elements/customLink.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type CustomLinkProps = {
|
||||||
|
path: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomLink({ path, text }: CustomLinkProps) {
|
||||||
|
return (
|
||||||
|
<Link href={path} className="overflow-hidden rounded-md">
|
||||||
|
<h1>{text}</h1>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
components/elements/error.tsx
Normal file
10
components/elements/error.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { HomeLink } from './homeLink';
|
||||||
|
|
||||||
|
export default function ErrorComponent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Oops. Something went wrong. Please try later :(</h1>
|
||||||
|
<HomeLink />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/elements/homeLink.tsx
Normal file
5
components/elements/homeLink.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CustomLink } from './customLink';
|
||||||
|
|
||||||
|
export function HomeLink() {
|
||||||
|
return <CustomLink path={`/`} text={`Home`} />;
|
||||||
|
}
|
||||||
11
components/elements/success.tsx
Normal file
11
components/elements/success.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { HomeLink } from './homeLink';
|
||||||
|
|
||||||
|
export function SuccessComponent(message: string) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Success!</h1>
|
||||||
|
<h3>{message}</h3>
|
||||||
|
<HomeLink />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/emails/newsletter.tsx
Normal file
19
components/emails/newsletter.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Container } from '@react-email/container';
|
||||||
|
import { Html } from '@react-email/html';
|
||||||
|
import { Section } from '@react-email/section';
|
||||||
|
import { Text } from '@react-email/text';
|
||||||
|
import { container, main, paragraph } from './utils/styling';
|
||||||
|
|
||||||
|
export default function NewsletterEmail(ids: number[]) {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Section style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Text style={paragraph}>
|
||||||
|
These were the ids retrieved: {ids.join(', ')}
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
components/emails/subscribe.tsx
Normal file
23
components/emails/subscribe.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Container } from '@react-email/container';
|
||||||
|
import { Html } from '@react-email/html';
|
||||||
|
import { Section } from '@react-email/section';
|
||||||
|
import { Text } from '@react-email/text';
|
||||||
|
import { container, main, paragraph } from './utils/styling';
|
||||||
|
|
||||||
|
export default function SubscribeEmail(code: string) {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Section style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Text style={paragraph}>
|
||||||
|
To confirm the subscription, please click{' '}
|
||||||
|
<a href={`${process.env.HOME_URL}/confirmation?code=${code}`}>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/emails/unsubscribe.tsx
Normal file
19
components/emails/unsubscribe.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Container } from '@react-email/container';
|
||||||
|
import { Html } from '@react-email/html';
|
||||||
|
import { Section } from '@react-email/section';
|
||||||
|
import { Text } from '@react-email/text';
|
||||||
|
import { container, main, paragraph } from './utils/styling';
|
||||||
|
|
||||||
|
export default function UnsubscribeEmail() {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Section style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Text style={paragraph}>
|
||||||
|
You have unsubscribed from the newsletter.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/emails/utils/styling.ts
Normal file
15
components/emails/utils/styling.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const main = {
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const container = {
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '20px 0 48px',
|
||||||
|
width: '580px'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paragraph = {
|
||||||
|
fontSize: '18px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
color: '#484848'
|
||||||
|
};
|
||||||
58
components/pages/confirmation.tsx
Normal file
58
components/pages/confirmation.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ResponseSchema } from '../../utils/types';
|
||||||
|
import { HomeLink } from '../elements/homeLink';
|
||||||
|
|
||||||
|
export const ConfirmationPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/confirmation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
const response: z.infer<typeof ResponseSchema> = await res.json();
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
setMessage(response.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [code, router]);
|
||||||
|
|
||||||
|
if (!loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{message}</h1>
|
||||||
|
<HomeLink />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Verifying...</h1>
|
||||||
|
<HomeLink />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
components/pages/subscribe.tsx
Normal file
87
components/pages/subscribe.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ResponseSchema } from '../../utils/types';
|
||||||
|
import { CustomLink } from '../elements/customLink';
|
||||||
|
import ErrorComponent from '../elements/error';
|
||||||
|
import { HomeLink } from '../elements/homeLink';
|
||||||
|
import { SuccessComponent } from '../elements/success';
|
||||||
|
|
||||||
|
export const SubscribeForm = () => {
|
||||||
|
const [completed, setCompleted] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (honeypotRef.current) {
|
||||||
|
honeypotRef.current.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
if (data.get('name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.get('email'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.ok) {
|
||||||
|
throw new Error(`Invalid response: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formResponse: z.infer<typeof ResponseSchema> =
|
||||||
|
await response.json();
|
||||||
|
|
||||||
|
setMessage(formResponse.message);
|
||||||
|
setCompleted(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Subscribe error', error);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return ErrorComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
return SuccessComponent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form className="container" onSubmit={handleSubmit}>
|
||||||
|
<h1>Subscribe to newsletter</h1>
|
||||||
|
<div className="email block">
|
||||||
|
<label htmlFor="frm-email">Email</label>
|
||||||
|
<input
|
||||||
|
placeholder="example@email.com"
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="name" ref={honeypotRef} />
|
||||||
|
<div className="button block">
|
||||||
|
<button type="submit">Subscribe</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<HomeLink />
|
||||||
|
<CustomLink path={`/unsubscribe`} text="Unsubscribe" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
components/pages/unsubscribe.tsx
Normal file
87
components/pages/unsubscribe.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ResponseSchema } from '../../utils/types';
|
||||||
|
import { CustomLink } from '../elements/customLink';
|
||||||
|
import ErrorComponent from '../elements/error';
|
||||||
|
import { HomeLink } from '../elements/homeLink';
|
||||||
|
import { SuccessComponent } from '../elements/success';
|
||||||
|
|
||||||
|
export const UnsubscribeForm = () => {
|
||||||
|
const [completed, setCompleted] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (honeypotRef.current) {
|
||||||
|
honeypotRef.current.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
if (data.get('name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.get('email'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.ok) {
|
||||||
|
throw new Error(`Invalid response: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formResponse: z.infer<typeof ResponseSchema> =
|
||||||
|
await response.json();
|
||||||
|
|
||||||
|
setMessage(formResponse.message);
|
||||||
|
setCompleted(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Unsubscribe error', error);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return ErrorComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
return SuccessComponent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form className="container" onSubmit={handleSubmit}>
|
||||||
|
<h1>Unsubscribe from newsletter</h1>
|
||||||
|
<div className="email block">
|
||||||
|
<label htmlFor="frm-email">Email</label>
|
||||||
|
<input
|
||||||
|
placeholder="example@email.com"
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="name" ref={honeypotRef} />
|
||||||
|
<div className="button block">
|
||||||
|
<button type="submit">Unsubscribe</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<HomeLink />
|
||||||
|
<CustomLink path={`/subscribe`} text="Subscribe" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
reactStrictMode: true
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-newsletter",
|
"name": "nextjs-hackernews",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
|
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
|
||||||
"author": "riccardo.s@hey.com",
|
"author": "riccardo.s@hey.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,7 +24,14 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zod-validation-error": "^2.1.0"
|
"zod-validation-error": "^2.1.0",
|
||||||
|
"@react-email/container": "^0.0.10",
|
||||||
|
"@react-email/html": "^0.0.6",
|
||||||
|
"@react-email/section": "^0.0.10",
|
||||||
|
"@react-email/text": "^0.0.6",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
|
"react-email": "^1.9.5",
|
||||||
|
"resend": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^18.4.3",
|
"@commitlint/cli": "^18.4.3",
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
// PrismaClient is attached to the `global` object in development to prevent
|
|
||||||
// exhausting your database connection limit.
|
|
||||||
//
|
|
||||||
// Learn more:
|
|
||||||
// https://pris.ly/d/help/next-js-best-practices
|
|
||||||
|
|
||||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
|||||||
@@ -3,28 +3,32 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @default(cuid()) @id
|
id String @id @default(cuid())
|
||||||
email String? @unique
|
email String @unique
|
||||||
targetingAllowed Boolean @default(false)
|
code String @unique
|
||||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
confirmed Boolean @default(false)
|
||||||
|
deleted Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||||
|
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model News {
|
model News {
|
||||||
id Float @unique @id
|
id Float @id @unique
|
||||||
title String
|
title String
|
||||||
text String?
|
text String?
|
||||||
type String
|
type String
|
||||||
by String
|
by String
|
||||||
time Float
|
time Float
|
||||||
url String?
|
url String?
|
||||||
score Float
|
score Float
|
||||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||||
|
|
||||||
@@map(name: "news")
|
@@map(name: "news")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", ".next"]
|
||||||
}
|
}
|
||||||
|
|||||||
6
utils/apiResponse.ts
Normal file
6
utils/apiResponse.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function ApiResponse(status: number, message: string) {
|
||||||
|
const response = new Response(message, { status });
|
||||||
|
response.headers.set('Access-Control-Allow-Origin', process.env.HOME_URL!);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
24
utils/sender.ts
Normal file
24
utils/sender.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
export async function sendEmail(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
template: JSX.Element
|
||||||
|
) {
|
||||||
|
const resend = new Resend(process.env.RESEND_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: process.env.RESEND_FROM!,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react: template,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ResponseSchema = z.object({
|
export const ResponseSchema = z.object({
|
||||||
message: z.string()
|
message: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SubscribeFormSchema = z.object({
|
export const SubscribeFormSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
targetingAllowed: z.boolean()
|
});
|
||||||
|
|
||||||
|
export const ConfirmationSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UnsubscribeFormSchema = z.object({
|
export const UnsubscribeFormSchema = z.object({
|
||||||
email: z.string().email()
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NewsSchema = z.object({
|
export const NewsSchema = z.object({
|
||||||
@@ -21,5 +24,5 @@ export const NewsSchema = z.object({
|
|||||||
by: z.string(),
|
by: z.string(),
|
||||||
time: z.number(),
|
time: z.number(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
score: z.number()
|
score: z.number(),
|
||||||
});
|
});
|
||||||
31
vercel.json
31
vercel.json
@@ -4,5 +4,36 @@
|
|||||||
"path": "/api/cron",
|
"path": "/api/cron",
|
||||||
"schedule": "0 6 * * *"
|
"schedule": "0 6 * * *"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/api/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Methods",
|
||||||
|
"value": "GET, POST, OPTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Headers",
|
||||||
|
"value": "Content-Type, Accept"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Strict-Transport-Security",
|
||||||
|
"value": "max-age=63072000; includeSubDomains; preload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Security-Policy",
|
||||||
|
"value": "default-src 'none'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "X-Content-Type-Options",
|
||||||
|
"value": "nosniff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "X-Frame-Options",
|
||||||
|
"value": "DENY"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user