feat: user activation and emails
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
# Created by Vercel CLI
|
||||
NX_DAEMON=""
|
||||
POSTGRES_DATABASE=""
|
||||
POSTGRES_HOST=""
|
||||
@@ -23,4 +22,8 @@ VERCEL_GIT_REPO_ID=""
|
||||
VERCEL_GIT_REPO_OWNER=""
|
||||
VERCEL_GIT_REPO_SLUG=""
|
||||
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
|
||||
|
||||
Basics:
|
||||
|
||||
- A proper UI
|
||||
- Email templates
|
||||
- Subscribe email
|
||||
- Unsubscribe email
|
||||
- Newsletter email cron job
|
||||
- Cookiebot
|
||||
- Google Analytics
|
||||
- SEO
|
||||
- Captcha?
|
||||
- Tests
|
||||
|
||||
## 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 { z } from 'zod';
|
||||
import NewsletterEmail from '../../../components/emails/newsletter';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { NewsSchema } from '../../utils/types';
|
||||
import { singleNews, topNews } from '../../utils/urls';
|
||||
import { sendEmail } from '../../../utils/sender';
|
||||
import { NewsSchema } from '../../../utils/types';
|
||||
import { singleNews, topNews } from '../../../utils/urls';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (
|
||||
@@ -11,47 +13,22 @@ export async function GET(request: Request) {
|
||||
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());
|
||||
|
||||
console.log('topstories', topstories);
|
||||
|
||||
const newsPromises = topstories
|
||||
.splice(0, Number(process.env.NEWS_LIMIT))
|
||||
.map(async id => {
|
||||
console.log('id', id);
|
||||
const sourceNews: z.infer<typeof NewsSchema> = await fetch(
|
||||
singleNews(id)
|
||||
).then(res => res.json());
|
||||
|
||||
console.log('sourceNews', sourceNews);
|
||||
|
||||
return await prisma.news.upsert({
|
||||
create: {
|
||||
id,
|
||||
title: sourceNews.title,
|
||||
text: sourceNews.text,
|
||||
type: sourceNews.type,
|
||||
by: sourceNews.by,
|
||||
time: sourceNews.time,
|
||||
url: sourceNews.url,
|
||||
score: sourceNews.score
|
||||
...sourceNews,
|
||||
id
|
||||
},
|
||||
update: {
|
||||
title: sourceNews.title,
|
||||
text: sourceNews.text,
|
||||
type: sourceNews.type,
|
||||
by: sourceNews.by,
|
||||
time: sourceNews.time,
|
||||
url: sourceNews.url,
|
||||
score: sourceNews.score
|
||||
...sourceNews
|
||||
},
|
||||
where: {
|
||||
id
|
||||
@@ -64,5 +41,30 @@ async function hackernewsApi() {
|
||||
|
||||
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 { fromZodError } from 'zod-validation-error';
|
||||
import SubscribeEmail from '../../../components/emails/subscribe';
|
||||
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 async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const validation = SubscribeFormSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
const message = fromZodError(validation.error);
|
||||
return new Response(message.message, { status: 400 });
|
||||
return ApiResponse(400, 'Bad request');
|
||||
}
|
||||
|
||||
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({
|
||||
create: {
|
||||
email,
|
||||
targetingAllowed
|
||||
code
|
||||
},
|
||||
update: {
|
||||
targetingAllowed
|
||||
code
|
||||
},
|
||||
where: {
|
||||
email
|
||||
}
|
||||
});
|
||||
|
||||
await sendEmail([email], 'Welcome!', SubscribeEmail(code));
|
||||
|
||||
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 { fromZodError } from 'zod-validation-error';
|
||||
import UnsubscribeEmail from '../../../components/emails/unsubscribe';
|
||||
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 async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const validation = UnsubscribeFormSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
const message = fromZodError(validation.error);
|
||||
return new Response(message.message, { status: 400 });
|
||||
return ApiResponse(400, 'Bad request');
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email
|
||||
}
|
||||
});
|
||||
|
||||
if (user && !user.deleted) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email
|
||||
},
|
||||
data: {
|
||||
deleted: true
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
sendEmail([email], 'Unsubscribe confirmation', UnsubscribeEmail());
|
||||
}
|
||||
|
||||
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 = {
|
||||
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({
|
||||
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import { Button } from '../components/Button';
|
||||
import { VerticalLayout } from '../components/VerticalLayout';
|
||||
import { CustomLink } from '../components/elements/customLink';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<VerticalLayout>
|
||||
<h1>Home</h1>
|
||||
<Button label='Subscribe' onClick={() => router.push('/subscribe')} />
|
||||
<Button label='Unsubscribe' onClick={() => router.push('/unsubscribe')} />
|
||||
</VerticalLayout>
|
||||
<div>
|
||||
<CustomLink path='/subscribe' text='Subscribe' />
|
||||
<CustomLink path='/unsubscribe' text='Unsubscribe' />
|
||||
<CustomLink path='/privacy' text='Privacy' />
|
||||
</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 { 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';
|
||||
import { SubscribeForm } from '../../components/pages/subscribe';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
export default function Subscribe() {
|
||||
return <SubscribeForm />;
|
||||
}
|
||||
|
||||
@@ -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 { 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';
|
||||
import { UnsubscribeForm } from '../../components/pages/unsubscribe';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
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>
|
||||
);
|
||||
export default function Unsubscribe() {
|
||||
return <UnsubscribeForm />;
|
||||
}
|
||||
|
||||
@@ -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} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-newsletter",
|
||||
"version": "0.1.0",
|
||||
"name": "nextjs-hackernews",
|
||||
"version": "0.2.0",
|
||||
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
|
||||
"author": "riccardo.s@hey.com",
|
||||
"scripts": {
|
||||
@@ -24,7 +24,14 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"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": {
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
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
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: 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;
|
||||
|
||||
@@ -3,28 +3,32 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @default(cuid()) @id
|
||||
email String? @unique
|
||||
targetingAllowed Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
code String @unique
|
||||
confirmed Boolean @default(false)
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
model News {
|
||||
id Float @unique @id
|
||||
title String
|
||||
text String?
|
||||
type String
|
||||
by String
|
||||
time Float
|
||||
url String?
|
||||
score Float
|
||||
id Float @id @unique
|
||||
title String
|
||||
text String?
|
||||
type String
|
||||
by String
|
||||
time Float
|
||||
url String?
|
||||
score Float
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
||||
@@map(name: "news")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
}
|
||||
},
|
||||
"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';
|
||||
|
||||
export const ResponseSchema = z.object({
|
||||
message: z.string()
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const SubscribeFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
targetingAllowed: z.boolean()
|
||||
});
|
||||
|
||||
export const ConfirmationSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export const UnsubscribeFormSchema = z.object({
|
||||
email: z.string().email()
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const NewsSchema = z.object({
|
||||
@@ -21,5 +24,5 @@ export const NewsSchema = z.object({
|
||||
by: z.string(),
|
||||
time: z.number(),
|
||||
url: z.string().optional(),
|
||||
score: z.number()
|
||||
score: z.number(),
|
||||
});
|
||||
31
vercel.json
31
vercel.json
@@ -4,5 +4,36 @@
|
||||
"path": "/api/cron",
|
||||
"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