Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5362ff7599 | |||
| 21b3c3ba83 | |||
| 44b9793c60 | |||
| 35020f2499 | |||
| 20b09849bc | |||
| dc3850ac4d | |||
| e73262b3b3 | |||
| 7299a266f1 | |||
| e46cb018fd | |||
| a8d3bf1b3b | |||
|
|
6e75be19ed | ||
|
|
f7e9d5c494 | ||
|
|
c0caedd6fa | ||
| 2afe8e39b9 | |||
|
|
8ed813dd53 | ||
| 905054e3b5 | |||
| 271fa45937 | |||
| a38ddc727f | |||
|
|
72019caca2 | ||
|
|
449277be21 | ||
|
|
77211d58cc | ||
|
|
5673870649 | ||
|
|
f11299ed93 | ||
| 11c26df116 | |||
|
|
2752045c16 | ||
|
|
2616728128 | ||
|
|
a8de784981 | ||
| 867e6e65a5 | |||
| e39026c259 | |||
|
|
5c75e9390e | ||
|
|
cc71d9be21 |
42
.env.example
42
.env.example
@@ -1,39 +1,15 @@
|
|||||||
ADMIN_EMAIL=""
|
ADMIN_EMAIL=""
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
HOME_URL=""
|
NEXT_PUBLIC_HOME_URL=""
|
||||||
MAINTENANCE_MODE=""
|
OVHCLOUD_API_KEY=""
|
||||||
NEWS_LIMIT=""
|
MAINTENANCE_MODE="0"
|
||||||
|
NEWS_LIMIT="50"
|
||||||
|
NEWS_TO_USE="10"
|
||||||
NEXT_PUBLIC_BRAND_COUNTRY=""
|
NEXT_PUBLIC_BRAND_COUNTRY=""
|
||||||
NEXT_PUBLIC_BRAND_EMAIL=""
|
NEXT_PUBLIC_BRAND_EMAIL=""
|
||||||
NEXT_PUBLIC_BRAND_NAME=""
|
NEXT_PUBLIC_BRAND_NAME=""
|
||||||
NX_DAEMON=""
|
NEXT_PUBLIC_BRAND_OWNER_NAME=""
|
||||||
POSTGRES_DATABASE=""
|
DATABASE_URL=""
|
||||||
POSTGRES_HOST=""
|
SWEEGO_API_KEY=""
|
||||||
POSTGRES_PASSWORD=""
|
SWEEGO_FROM=""
|
||||||
POSTGRES_PRISMA_URL=""
|
|
||||||
POSTGRES_URL=""
|
|
||||||
POSTGRES_URL_NON_POOLING=""
|
|
||||||
POSTGRES_USER=""
|
|
||||||
RESEND_AUDIENCE=""
|
|
||||||
RESEND_FROM=""
|
|
||||||
RESEND_KEY=""
|
|
||||||
SECRET_HASH=""
|
SECRET_HASH=""
|
||||||
TURBO_REMOTE_ONLY=""
|
|
||||||
TURBO_RUN_SUMMARY=""
|
|
||||||
VERCEL="1"
|
|
||||||
VERCEL_ENV="development"
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
|
|
||||||
VERCEL_GIT_COMMIT_MESSAGE=""
|
|
||||||
VERCEL_GIT_COMMIT_REF=""
|
|
||||||
VERCEL_GIT_COMMIT_SHA=""
|
|
||||||
VERCEL_GIT_PREVIOUS_SHA=""
|
|
||||||
VERCEL_GIT_PROVIDER=""
|
|
||||||
VERCEL_GIT_PULL_REQUEST_ID=""
|
|
||||||
VERCEL_GIT_REPO_ID=""
|
|
||||||
VERCEL_GIT_REPO_OWNER=""
|
|
||||||
VERCEL_GIT_REPO_SLUG=""
|
|
||||||
VERCEL_URL=""
|
|
||||||
ANALYZE="false"
|
|
||||||
ANTHROPIC_API_KEY=""
|
|
||||||
NEWS_TO_USE=10
|
|
||||||
41
.gitea/workflows/deploy.yml
Normal file
41
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to VPS
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
|
||||||
|
ssh debian@51.210.247.57 << 'EOF'
|
||||||
|
cd /home/debian/newsletter-hackernews
|
||||||
|
git pull origin main
|
||||||
|
cd /home/debian/infrastructure
|
||||||
|
docker compose up -d --build newsletter
|
||||||
|
EOF
|
||||||
86
.github/workflows/ci.yml
vendored
Normal file
86
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-typecheck:
|
||||||
|
name: Lint & Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: yarn typecheck
|
||||||
|
|
||||||
|
- name: Run Prettier check
|
||||||
|
run: yarn format
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-typecheck
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Report deployment readiness
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: echo "✅ All checks passed - ready for deployment"
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
name: Commit Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate commit messages
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
yarn commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||||
|
else
|
||||||
|
yarn commitlint --from HEAD~1 --to HEAD --verbose
|
||||||
|
fi
|
||||||
18
Dockerfile
18
Dockerfile
@@ -4,12 +4,24 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn build
|
ARG NEXT_PUBLIC_BRAND_NAME
|
||||||
|
ARG NEXT_PUBLIC_BRAND_EMAIL
|
||||||
|
ARG NEXT_PUBLIC_BRAND_COUNTRY
|
||||||
|
ARG NEXT_PUBLIC_HOME_URL
|
||||||
|
ARG NEXT_PUBLIC_BRAND_OWNER_NAME
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
|
||||||
|
ENV NEXT_PUBLIC_BRAND_EMAIL=$NEXT_PUBLIC_BRAND_EMAIL
|
||||||
|
ENV NEXT_PUBLIC_BRAND_COUNTRY=$NEXT_PUBLIC_BRAND_COUNTRY
|
||||||
|
ENV NEXT_PUBLIC_HOME_URL=$NEXT_PUBLIC_HOME_URL
|
||||||
|
ENV NEXT_PUBLIC_BRAND_OWNER_NAME=$NEXT_PUBLIC_BRAND_OWNER_NAME
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "npm", "start" ]
|
||||||
85
README.md
85
README.md
@@ -1,54 +1,87 @@
|
|||||||
# Hackernews newsletter
|
# 📰 HackerNews Newsletter
|
||||||
|
|
||||||
## Future improvements
|
A Next.js application that aggregates HackerNews stories and delivers them as personalized daily newsletters.
|
||||||
|
|
||||||
- Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
|
## ✨ Features
|
||||||
|
|
||||||
## Some resources used
|
- 📧 Daily newsletter containing curated HackerNews stories
|
||||||
|
- ☁️ Vercel hosting integration
|
||||||
|
|
||||||
https://gradientbuttons.colorion.co/
|
## 🚀 Future improvements
|
||||||
https://codepen.io/alphardex/pen/vYEYGzp
|
|
||||||
|
|
||||||
## Commands
|
- ⏰ Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
|
||||||
|
|
||||||
Install vercel cli
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- ⚡ Next.js
|
||||||
|
- 🗄️ Prisma (Database ORM)
|
||||||
|
- 🚀 Vercel (Hosting)
|
||||||
|
- 📝 Custom email templates
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### 📋 Prerequisites
|
||||||
|
|
||||||
|
- 📦 Node.js
|
||||||
|
- 🐳 Docker
|
||||||
|
- 🔧 Vercel CLI
|
||||||
|
- 🧶 Yarn package manager
|
||||||
|
|
||||||
|
### 💻 Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/RiccardoSenica/newsletter-hackernews
|
||||||
|
cd hackernews-newsletter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up Vercel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Vercel CLI
|
||||||
yarn add -g vercel@latest
|
yarn add -g vercel@latest
|
||||||
```
|
|
||||||
|
|
||||||
Link to vercel
|
# Link to your Vercel project
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn vercel:link
|
yarn vercel:link
|
||||||
```
|
|
||||||
|
|
||||||
Pull env variables from vercel
|
# Pull environment variables
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn vercel:env
|
yarn vercel:env
|
||||||
```
|
```
|
||||||
|
|
||||||
Push Prisma schema to vercel
|
4. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Push Prisma schema to database
|
||||||
yarn db:push
|
yarn db:push
|
||||||
```
|
|
||||||
|
|
||||||
Generate Prisma client
|
# Generate Prisma client
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn prisma:generate
|
yarn prisma:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Reset Prisma database
|
### 🔧 Development
|
||||||
|
|
||||||
|
Run locally with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🗄️ Database Management
|
||||||
|
|
||||||
|
Reset database (⚠️ caution: this will delete all data):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn db:reset
|
yarn db:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
Run on Docker
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
```bash
|
- 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/)
|
||||||
docker-compose up --build
|
- ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp)
|
||||||
```
|
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import {
|
|||||||
} from '@utils/statusCodes';
|
} from '@utils/statusCodes';
|
||||||
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
|
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
if (!process.env.SWEEGO_API_KEY) {
|
||||||
throw new Error('Resend variables not set');
|
throw new Error('SWEEGO_API_KEY is not set');
|
||||||
}
|
}
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const validation = ConfirmationSchema.safeParse(body);
|
const validation = ConfirmationSchema.safeParse(body);
|
||||||
@@ -31,14 +30,6 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
code: validation.data.code
|
code: validation.data.code
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
|
import axios from 'axios';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import {
|
import {
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
import { getSingleNews, getTopNews } from '@utils/urls';
|
import { getSingleNews, getTopNews } from '@utils/urls';
|
||||||
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
|
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (
|
if (
|
||||||
@@ -19,17 +19,22 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const topStories: number[] = await fetch(getTopNews, {
|
const { data: topStories } = await axios.get<number[]>(getTopNews, {
|
||||||
cache: 'no-store'
|
headers: {
|
||||||
}).then(res => res.json());
|
'Cache-Control': 'no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.info(`Top stories ids: ${topStories}`);
|
console.info(`Top stories ids: ${topStories}`);
|
||||||
|
|
||||||
const newsPromises = topStories
|
const newsPromises = topStories
|
||||||
.slice(0, Number(process.env.NEWS_LIMIT))
|
.slice(0, Number(process.env.NEWS_LIMIT))
|
||||||
.map(id => fetch(getSingleNews(id)).then(res => res.json()));
|
.map(id => axios.get<NewsDatabaseType>(getSingleNews(id)));
|
||||||
|
|
||||||
const news: NewsDatabaseType[] = await Promise.all(newsPromises);
|
const newsResponses = await Promise.all(newsPromises);
|
||||||
|
const news: NewsDatabaseType[] = newsResponses.map(
|
||||||
|
response => response.data
|
||||||
|
);
|
||||||
|
|
||||||
const upsertPromises = news.map(async getSingleNews => {
|
const upsertPromises = news.map(async getSingleNews => {
|
||||||
const validation = NewsDatabaseSchema.safeParse(getSingleNews);
|
const validation = NewsDatabaseSchema.safeParse(getSingleNews);
|
||||||
@@ -65,23 +70,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
console.info(`Imported ${result.length} news.`);
|
console.info(`Imported ${result.length} news.`);
|
||||||
|
|
||||||
if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: process.env.RESEND_FROM,
|
|
||||||
to: [process.env.ADMIN_EMAIL],
|
|
||||||
subject: 'Newsletter: import cron job',
|
|
||||||
text: `Imported ${result.length} news out of these ids: ${topStories.join(', ')}.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatApiResponse(
|
return formatApiResponse(
|
||||||
STATUS_OK,
|
STATUS_OK,
|
||||||
`Imported ${newsPromises.length} news.`
|
`Imported ${newsPromises.length} news.`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
} else {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
return formatApiResponse(
|
return formatApiResponse(
|
||||||
STATUS_INTERNAL_SERVER_ERROR,
|
STATUS_INTERNAL_SERVER_ERROR,
|
||||||
INTERNAL_SERVER_ERROR
|
INTERNAL_SERVER_ERROR
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NewsletterTemplate } from '@components/email/Newsletter';
|
import { NewsletterTemplate } from '@components/email/Newsletter';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/sweego';
|
||||||
import {
|
import {
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
STATUS_INTERNAL_SERVER_ERROR,
|
STATUS_INTERNAL_SERVER_ERROR,
|
||||||
@@ -25,9 +25,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes
|
|
||||||
// this is to avoid sending the newsletter to the same users multiple times
|
|
||||||
// this is not a perfect solution, but it's good enough for now
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConfirmationTemplate } from '@components/email/Confirmation';
|
import { ConfirmationTemplate } from '@components/email/Confirmation';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/sweego';
|
||||||
import {
|
import {
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -12,14 +12,9 @@ import {
|
|||||||
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
|
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
|
||||||
throw new Error('RESEND_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const validation = SubscribeFormSchema.safeParse(body);
|
const validation = SubscribeFormSchema.safeParse(body);
|
||||||
@@ -36,8 +31,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
const code = crypto
|
const code = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(`${process.env.SECRET_HASH}${email}}`)
|
.update(`${process.env.SECRET_HASH}${email}}`)
|
||||||
@@ -53,19 +46,6 @@ export async function POST(request: NextRequest) {
|
|||||||
deleted: false
|
deleted: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const contact = await resend.contacts.get({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: ResponseType = {
|
const message: ResponseType = {
|
||||||
@@ -84,21 +64,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const contact = await resend.contacts.create({
|
|
||||||
email: email,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contact.data?.id) {
|
|
||||||
throw new Error('Failed to create Resend contact');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
code,
|
code
|
||||||
resendId: contact.data.id
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
|
import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/sweego';
|
||||||
import {
|
import {
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -11,17 +11,13 @@ import {
|
|||||||
} from '@utils/statusCodes';
|
} from '@utils/statusCodes';
|
||||||
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
|
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
|
||||||
throw new Error('RESEND_AUDIENCE is not set');
|
|
||||||
}
|
|
||||||
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) {
|
||||||
return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
||||||
}
|
}
|
||||||
@@ -34,24 +30,13 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && !user.deleted) {
|
if (user) {
|
||||||
await prisma.user.update({
|
await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
email
|
email
|
||||||
},
|
|
||||||
data: {
|
|
||||||
deleted: true
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = await sender([email], UnsubscribeTemplate());
|
const sent = await sender([email], UnsubscribeTemplate());
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CardDescription } from '@components/Card';
|
|||||||
import { CustomCard } from '@components/CustomCard';
|
import { CustomCard } from '@components/CustomCard';
|
||||||
import { SchemaOrg } from '@components/SchemaOrg';
|
import { SchemaOrg } from '@components/SchemaOrg';
|
||||||
import { ResponseType } from '@utils/validationSchemas';
|
import { ResponseType } from '@utils/validationSchemas';
|
||||||
|
import axios from 'axios';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -23,32 +24,31 @@ const ConfirmationPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/confirmation', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/confirmation',
|
||||||
|
{
|
||||||
|
code: code
|
||||||
|
},
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
}
|
||||||
code: code
|
);
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!data.success) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: ResponseType = await res.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!response.success) {
|
|
||||||
router.push('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(response.message);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
} else {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -86,7 +86,7 @@ const Confirmation = () => {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Hackernews Newsletter',
|
name: 'Hackernews Newsletter',
|
||||||
title: 'Subscription Confirmation',
|
title: 'Subscription Confirmation',
|
||||||
url: `${process.env.HOME_URL}/confirmation`
|
url: `${process.env.NEXT_PUBLIC_HOME_URL}/confirmation`
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Tiles } from '@components/tiles/Tiles';
|
import { Tiles } from '@components/tiles/Tiles';
|
||||||
import { cn } from '@utils/cn';
|
import { cn } from '@utils/cn';
|
||||||
import { Analytics } from '@vercel/analytics/react';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter as FontSans } from 'next/font/google';
|
import { Inter as FontSans } from 'next/font/google';
|
||||||
|
import Script from 'next/script';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -34,8 +34,12 @@ export default function RootLayout({
|
|||||||
<Tiles>
|
<Tiles>
|
||||||
<div className='z-10'>{children}</div>
|
<div className='z-10'>{children}</div>
|
||||||
</Tiles>
|
</Tiles>
|
||||||
<Analytics />
|
|
||||||
</body>
|
</body>
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src='https://analytics.frompixels.com/script.js'
|
||||||
|
data-website-id='588e7b7d-e9cd-4b96-94bf-8269c499b0a2'
|
||||||
|
/>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/page.tsx
34
app/page.tsx
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@utils/validationSchemas';
|
} from '@utils/validationSchemas';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
@@ -30,7 +31,7 @@ export const Home = () => {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Hackernews Newsletter',
|
name: 'Hackernews Newsletter',
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
url: process.env.HOME_URL
|
url: process.env.NEXT_PUBLIC_HOME_URL
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm<SubscribeFormType>({
|
const form = useForm<SubscribeFormType>({
|
||||||
@@ -45,29 +46,28 @@ export const Home = () => {
|
|||||||
async function handleSubmit(values: SubscribeFormType) {
|
async function handleSubmit(values: SubscribeFormType) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/subscribe', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/subscribe',
|
||||||
|
{
|
||||||
|
email: values.email
|
||||||
|
},
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
}
|
||||||
email: values.email
|
);
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response?.ok) {
|
if (!data.success) {
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formResponse: ResponseType = await response.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!formResponse.success) {
|
|
||||||
throw Error(formResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(formResponse.message);
|
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -9,434 +9,106 @@ const Privacy = () => {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Hackernews Newsletter',
|
name: 'Hackernews Newsletter',
|
||||||
title: 'Privacy Policy',
|
title: 'Privacy Policy',
|
||||||
url: `${process.env.HOME_URL}/privacy`
|
url: `${process.env.NEXT_PUBLIC_HOME_URL}/privacy`
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div className='privacy-content my-2 max-h-[50vh] space-y-1 overflow-auto'>
|
<div className='privacy-content my-2 max-h-[50vh] space-y-1 overflow-auto'>
|
||||||
|
<h2>Who We Are</h2>
|
||||||
<p className='leading-relaxed'>
|
<p className='leading-relaxed'>
|
||||||
This Privacy Policy describes Our policies and procedures on the
|
Data controller: {process.env.NEXT_PUBLIC_BRAND_OWNER_NAME}, an
|
||||||
collection, use and disclosure of Your information when You use the
|
individual based in {process.env.NEXT_PUBLIC_BRAND_COUNTRY}.
|
||||||
Service and tells You about Your privacy rights and how the law protects
|
|
||||||
You.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We use Your Personal data to provide and improve the Service. By using
|
Contact:{' '}
|
||||||
the Service, You agree to the collection and use of information in
|
|
||||||
accordance with this Privacy Policy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Interpretation and Definitions</h2>
|
|
||||||
<h3>Interpretation</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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 className='leading-relaxed'>
|
|
||||||
For the purposes of this Privacy Policy:
|
|
||||||
</p>
|
|
||||||
<ul className='list-disc space-y-4 pl-6'>
|
|
||||||
<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{' '}
|
|
||||||
{process.env.NEXT_PUBLIC_BRAND_NAME}, 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 {process.env.NEXT_PUBLIC_BRAND_NAME}.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
<strong>Country</strong> refers to:{' '}
|
|
||||||
{process.env.NEXT_PUBLIC_BRAND_COUNTRY}
|
|
||||||
</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>Data Collection and Usage</h2>
|
|
||||||
<h3>Types of Data Collected</h3>
|
|
||||||
|
|
||||||
<h4>Personal Data</h4>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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 className='list-disc space-y-4 pl-6'>
|
|
||||||
<li>
|
|
||||||
<p>Email address</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>Usage Data</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Usage Data</h4>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
Usage Data is collected automatically when using the Service.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Usage Data may include information such as Your Device's 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>
|
|
||||||
|
|
||||||
<h2>Use of Your Personal Data</h2>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
The Company may use Personal Data for the following purposes:
|
|
||||||
</p>
|
|
||||||
<ul className='list-disc space-y-4 pl-6'>
|
|
||||||
<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's 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 className='list-disc space-y-4 pl-6'>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<h2>Data Handling and Security</h2>
|
|
||||||
|
|
||||||
<h3>Retention of Your Personal Data</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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 className='leading-relaxed'>
|
|
||||||
Your information, including Personal Data, is processed at the
|
|
||||||
Company's 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>
|
|
||||||
|
|
||||||
<h3>Security of Your Personal Data</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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>
|
|
||||||
|
|
||||||
<h4 className='text-lg font-medium text-gray-800'>
|
|
||||||
Delete Your Personal Data
|
|
||||||
</h4>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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>
|
|
||||||
|
|
||||||
<h2>Legal Disclosures</h2>
|
|
||||||
|
|
||||||
<h3>Business Transactions</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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>
|
|
||||||
|
|
||||||
<h3>Law Enforcement</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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>
|
|
||||||
|
|
||||||
<h3>Other Legal Requirements</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
The Company may disclose Your Personal Data in the good faith belief
|
|
||||||
that such action is necessary to:
|
|
||||||
</p>
|
|
||||||
<ul className='list-disc space-y-4 pl-6'>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<h2>Additional Information</h2>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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>
|
|
||||||
<h3>Children's Privacy</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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's consent before We collect and use that
|
|
||||||
information.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Links to Other Websites</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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's 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>
|
|
||||||
|
|
||||||
<h3>Changes to this Privacy Policy</h3>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
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 Information</h2>
|
|
||||||
<p className='leading-relaxed'>
|
|
||||||
If you have any questions about this Privacy Policy, You can contact us
|
|
||||||
by writing to{' '}
|
|
||||||
<a
|
<a
|
||||||
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
||||||
className='text-purple-600 hover:text-purple-700'
|
className='text-purple-600 hover:text-purple-700'
|
||||||
>
|
>
|
||||||
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
|
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
|
||||||
</a>
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>What We Collect</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
Your email address (required to send the newsletter).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Why We Collect It</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
To deliver the daily {process.env.NEXT_PUBLIC_BRAND_NAME}{' '}
|
||||||
|
newsletter—a digest of top Hacker News stories with AI-generated
|
||||||
|
commentary.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We do not sell products, track your activity beyond essential delivery,
|
||||||
|
or share your data for marketing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Legal Basis</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
Your explicit consent via double opt-in signup (you receive a
|
||||||
|
confirmation email with an activation link).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Third Parties</h2>
|
||||||
|
<ul className='list-disc space-y-4 pl-6'>
|
||||||
|
<li>
|
||||||
|
<strong>Email delivery:</strong> Resend (US). GDPR-compliant email
|
||||||
|
infrastructure.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Hosting & analytics:</strong> Vercel (US). Analytics are
|
||||||
|
anonymized and aggregated—no personal data beyond your email is
|
||||||
|
collected.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Content source:</strong> Public Hacker News API (Y Combinator,
|
||||||
|
US).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Your Rights</h2>
|
||||||
|
<ul className='list-disc space-y-4 pl-6'>
|
||||||
|
<li>Unsubscribe anytime via the link in every email</li>
|
||||||
|
<li>
|
||||||
|
Request deletion of your email by contacting{' '}
|
||||||
|
<a
|
||||||
|
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
||||||
|
className='text-purple-600 hover:text-purple-700'
|
||||||
|
>
|
||||||
|
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Upon unsubscribe or deletion request, your email is permanently
|
||||||
|
deleted from our database
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Data Retention</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
We retain your email address only until you unsubscribe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>International Transfers</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
We rely on EU-approved safeguards including Standard Contractual Clauses
|
||||||
|
to ensure adequate protection when using US-based services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Changes</h2>
|
||||||
|
<p className='leading-relaxed'>
|
||||||
|
We may update this policy occasionally. The latest version will always
|
||||||
|
be available at{' '}
|
||||||
|
<a
|
||||||
|
href={`${process.env.NEXT_PUBLIC_HOME_URL}/privacy`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-purple-600 hover:text-purple-700'
|
||||||
|
>
|
||||||
|
{process.env.NEXT_PUBLIC_HOME_URL}/privacy
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +120,7 @@ const Privacy = () => {
|
|||||||
<CustomCard
|
<CustomCard
|
||||||
className='max-90vh max-90vw'
|
className='max-90vh max-90vw'
|
||||||
title='Privacy Policy'
|
title='Privacy Policy'
|
||||||
description='Last updated: November 23, 2024'
|
description='Last updated: January 28, 2026'
|
||||||
content={body}
|
content={body}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
userAgent: '*',
|
userAgent: '*',
|
||||||
disallow: ''
|
disallow: ''
|
||||||
},
|
},
|
||||||
sitemap: `${process.env.HOME_URL!}/sitemap.xml`
|
sitemap: `${process.env.NEXT_PUBLIC_HOME_URL!}/sitemap.xml`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { MetadataRoute } from 'next';
|
|||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: process.env.HOME_URL!,
|
url: process.env.NEXT_PUBLIC_HOME_URL!,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 1
|
priority: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${process.env.HOME_URL!}/privacy`,
|
url: `${process.env.NEXT_PUBLIC_HOME_URL!}/privacy`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.5
|
priority: 0.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${process.env.HOME_URL!}/unsubscribe`,
|
url: `${process.env.NEXT_PUBLIC_HOME_URL!}/unsubscribe`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.2
|
priority: 0.2
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
UnsubscribeFormSchema,
|
UnsubscribeFormSchema,
|
||||||
UnsubscribeFormType
|
UnsubscribeFormType
|
||||||
} from '@utils/validationSchemas';
|
} from '@utils/validationSchemas';
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ const Unsubscribe = () => {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Hackernews Newsletter',
|
name: 'Hackernews Newsletter',
|
||||||
title: 'Unsubscribe',
|
title: 'Unsubscribe',
|
||||||
url: `${process.env.HOME_URL}/unsubscribe`
|
url: `${process.env.NEXT_PUBLIC_HOME_URL}/unsubscribe`
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm<UnsubscribeFormType>({
|
const form = useForm<UnsubscribeFormType>({
|
||||||
@@ -52,29 +54,28 @@ const Unsubscribe = () => {
|
|||||||
async function handleSubmit(values: UnsubscribeFormType) {
|
async function handleSubmit(values: UnsubscribeFormType) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/unsubscribe', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/unsubscribe',
|
||||||
|
{
|
||||||
|
email: values.email
|
||||||
|
},
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
}
|
||||||
email: values.email
|
);
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response?.ok) {
|
if (!data.success) {
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formResponse: ResponseType = await response.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!formResponse.success) {
|
|
||||||
throw Error(formResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(formResponse.message);
|
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const ConfirmationTemplate = (code: string) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`${process.env.HOME_URL}/confirmation?code=${code}`}
|
href={`${process.env.NEXT_PUBLIC_HOME_URL}/confirmation?code=${code}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { summirize } from '@utils/anthropic/summarize';
|
import { summirize } from '@utils/ai/summarize';
|
||||||
import { NewsType } from '@utils/validationSchemas';
|
import { NewsType } from '@utils/validationSchemas';
|
||||||
import createDOMPurify from 'isomorphic-dompurify';
|
import createDOMPurify from 'isomorphic-dompurify';
|
||||||
import { Template } from './Template';
|
import { Template } from './Template';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const UnsubscribeTemplate = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`${process.env.HOME_URL}/`}
|
href={`${process.env.NEXT_PUBLIC_HOME_URL}/`}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export const Footer = () => {
|
|||||||
<span>
|
<span>
|
||||||
Click{' '}
|
Click{' '}
|
||||||
<a
|
<a
|
||||||
href={`${process.env.HOME_URL}/unsubscribe`}
|
href={`${process.env.NEXT_PUBLIC_HOME_URL}/unsubscribe`}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
here
|
here
|
||||||
@@ -139,7 +139,7 @@ export const Footer = () => {
|
|||||||
>
|
>
|
||||||
<Shield size={14} color='#386FA4' />
|
<Shield size={14} color='#386FA4' />
|
||||||
<a
|
<a
|
||||||
href={`${process.env.HOME_URL}/privacy`}
|
href={`${process.env.NEXT_PUBLIC_HOME_URL}/privacy`}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
@@ -157,7 +157,7 @@ export const Footer = () => {
|
|||||||
>
|
>
|
||||||
<Home size={14} color='#386FA4' />
|
<Home size={14} color='#386FA4' />
|
||||||
<a
|
<a
|
||||||
href={process.env.HOME_URL}
|
href={process.env.NEXT_PUBLIC_HOME_URL}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
Visit Website
|
Visit Website
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NewsTileType } from '@utils/validationSchemas';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Tile } from './components/Tile';
|
import { Tile } from './components/Tile';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface TilesProps {
|
interface TilesProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -22,11 +23,19 @@ export const Tiles = ({ children }: TilesProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getNews() {
|
async function getNews() {
|
||||||
const news: NewsTileType[] = await fetch('/api/news').then(res =>
|
try {
|
||||||
res.json()
|
const { data: news } = await axios.get<NewsTileType[]>('/api/news');
|
||||||
);
|
|
||||||
|
|
||||||
setNews(news);
|
setNews(news);
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error(
|
||||||
|
'Failed to fetch news:',
|
||||||
|
error.response?.data || error.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch news:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!news) {
|
if (!news) {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
postgres:
|
||||||
build:
|
image: postgres:16-alpine
|
||||||
context: .
|
container_name: newsletter-db
|
||||||
dockerfile: Dockerfile
|
environment:
|
||||||
|
- POSTGRES_USER=newsletter
|
||||||
|
- POSTGRES_PASSWORD=newsletter
|
||||||
|
- POSTGRES_DB=newsletter
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|||||||
10450
package-lock.json
generated
Normal file
10450
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,26 +21,25 @@
|
|||||||
"prisma:reset": "npx prisma db push --force-reset"
|
"prisma:reset": "npx prisma db push --force-reset"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.27.3",
|
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@next/bundle-analyzer": "^14.2.5",
|
"@next/bundle-analyzer": "^14.2.5",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@vercel/analytics": "^1.1.1",
|
"axios": "^1.12.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"isomorphic-dompurify": "^2.15.0",
|
"isomorphic-dompurify": "^2.15.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"next": "^14.2.10",
|
"next": "^15.5.9",
|
||||||
|
"openai": "^4.77.0",
|
||||||
"postcss-nesting": "^12.0.2",
|
"postcss-nesting": "^12.0.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"resend": "^3.1.0",
|
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^18.4.3",
|
"@commitlint/cli": "^18.4.3",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `resendId` on the `users` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" DROP COLUMN "resendId";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Rename the column without dropping it
|
||||||
|
ALTER TABLE news RENAME COLUMN created_at TO "createdAt";
|
||||||
@@ -4,13 +4,11 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resendId String
|
|
||||||
email String @unique
|
email String @unique
|
||||||
code String @unique
|
code String @unique
|
||||||
confirmed Boolean @default(false)
|
confirmed Boolean @default(false)
|
||||||
@@ -30,7 +28,7 @@ model News {
|
|||||||
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: "news")
|
@@map(name: "news")
|
||||||
}
|
}
|
||||||
|
|||||||
103
utils/ai/client.ts
Normal file
103
utils/ai/client.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import { BaseTool } from './tool';
|
||||||
|
|
||||||
|
let ovhAI: OpenAI | null = null;
|
||||||
|
|
||||||
|
function getClient(): OpenAI {
|
||||||
|
if (!ovhAI) {
|
||||||
|
ovhAI = new OpenAI({
|
||||||
|
apiKey: process.env.OVHCLOUD_API_KEY,
|
||||||
|
baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ovhAI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export async function getMessage<T>(
|
||||||
|
text: string,
|
||||||
|
baseTool: BaseTool
|
||||||
|
): Promise<T> {
|
||||||
|
const requiredFields = baseTool.input_schema.required
|
||||||
|
? [...baseTool.input_schema.required]
|
||||||
|
: Object.keys(baseTool.input_schema.properties);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
console.info(`OVH AI request attempt ${attempt}/${MAX_RETRIES}`);
|
||||||
|
|
||||||
|
const completion = await getClient().chat.completions.create({
|
||||||
|
model: 'Meta-Llama-3_3-70B-Instruct',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 16000,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: baseTool.name,
|
||||||
|
description: baseTool.input_schema.description || '',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: baseTool.input_schema.properties,
|
||||||
|
required: requiredFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tool_choice: {
|
||||||
|
type: 'function',
|
||||||
|
function: { name: baseTool.name }
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a professional tech journalist creating newsletter content.
|
||||||
|
|
||||||
|
CRITICAL REQUIREMENTS:
|
||||||
|
1. You MUST call the function with ALL required fields populated
|
||||||
|
2. Required fields: ${requiredFields.join(', ')}
|
||||||
|
3. Every field must be fully populated with high-quality content
|
||||||
|
4. Do NOT leave any field as null, undefined, or empty`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = completion.choices[0]?.message;
|
||||||
|
|
||||||
|
if (!message?.tool_calls || message.tool_calls.length === 0) {
|
||||||
|
throw new Error('No function call found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = message.tool_calls[0];
|
||||||
|
|
||||||
|
if (toolCall.function.name !== baseTool.name) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected tool ${baseTool.name} but got ${toolCall.function.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(toolCall.function.arguments);
|
||||||
|
console.info('OVH AI response:', result);
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Attempt ${attempt} failed:`, error);
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
const delay = 1000 * attempt;
|
||||||
|
console.info(`Retrying in ${delay}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('All retry attempts failed');
|
||||||
|
throw lastError || new Error('OVH AI Endpoints client error.');
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ export interface BaseTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewsletterTool {
|
export interface NewsletterTool {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -18,20 +25,25 @@ export const newsletterTool: BaseTool = {
|
|||||||
name: 'NewsletterTool' as const,
|
name: 'NewsletterTool' as const,
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
description: 'Newsletter',
|
description:
|
||||||
|
'Generate a tech newsletter with title, content, and focus sections',
|
||||||
properties: {
|
properties: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The title of the newsletter'
|
description:
|
||||||
|
'The title of the newsletter (40-50 characters, capturing key themes)'
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The main content of the newsletter'
|
description:
|
||||||
|
'The main content of the newsletter (300-400 words, HTML formatted with paragraph tags and links)'
|
||||||
},
|
},
|
||||||
focus: {
|
focus: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The text of the focus segment'
|
description:
|
||||||
}
|
'Forward-looking assessment of key developments and trends'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
required: ['title', 'content', 'focus'] as const
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk';
|
|
||||||
import { BaseTool } from './tool';
|
|
||||||
|
|
||||||
export async function getMessage<T>(text: string, tool: BaseTool) {
|
|
||||||
const anthropic = new Anthropic({
|
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info('Anthropic request with text: ', text);
|
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 2048,
|
|
||||||
messages: [{ role: 'user', content: text }],
|
|
||||||
tools: [tool]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info('Anthropic response: ', response);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = response.content as [
|
|
||||||
{ type: string; text: string },
|
|
||||||
{ type: string; input: object } // object of type V
|
|
||||||
];
|
|
||||||
|
|
||||||
return data[1].input as T;
|
|
||||||
} catch (error) {
|
|
||||||
throw Error(JSON.stringify(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
interface EmailTemplate {
|
|
||||||
subject: string;
|
|
||||||
template: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sender(
|
|
||||||
recipients: string[],
|
|
||||||
{ subject, template }: EmailTemplate
|
|
||||||
) {
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
console.info(`${subject} email skipped for having zero recipients`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (recipients.length == 1) {
|
|
||||||
response = await resend.emails.send({
|
|
||||||
from: process.env.RESEND_FROM!,
|
|
||||||
to: recipients[0],
|
|
||||||
subject,
|
|
||||||
react: template,
|
|
||||||
headers: {
|
|
||||||
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await resend.batch.send(
|
|
||||||
recipients.map(recipient => {
|
|
||||||
return {
|
|
||||||
from: process.env.RESEND_FROM!,
|
|
||||||
to: recipient,
|
|
||||||
subject,
|
|
||||||
react: template,
|
|
||||||
headers: {
|
|
||||||
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = response;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`${subject} email sent to ${recipients.length} recipients`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
90
utils/sweego.ts
Normal file
90
utils/sweego.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
interface EmailTemplate {
|
||||||
|
subject: string;
|
||||||
|
template: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWEEGO_API_URL = 'https://api.sweego.io/send';
|
||||||
|
|
||||||
|
const renderTemplate = async (template: JSX.Element): Promise<string> => {
|
||||||
|
const { renderToStaticMarkup } = await import('react-dom/server');
|
||||||
|
const html = renderToStaticMarkup(template);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
|
||||||
|
${html}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sender(
|
||||||
|
recipients: string[],
|
||||||
|
{ subject, template }: EmailTemplate
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!process.env.SWEEGO_API_KEY) {
|
||||||
|
throw new Error('SWEEGO_API_KEY is not set');
|
||||||
|
}
|
||||||
|
if (!process.env.SWEEGO_FROM) {
|
||||||
|
throw new Error('SWEEGO_FROM is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
console.info(`${subject} email skipped for having zero recipients`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = await renderTemplate(template);
|
||||||
|
const fromName = process.env.NEXT_PUBLIC_BRAND_NAME || 'Newsletter';
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(SWEEGO_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Api-Key': process.env.SWEEGO_API_KEY
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel: 'email',
|
||||||
|
provider: 'sweego',
|
||||||
|
recipients: [{ email: recipient }],
|
||||||
|
from: {
|
||||||
|
name: fromName,
|
||||||
|
email: process.env.SWEEGO_FROM
|
||||||
|
},
|
||||||
|
subject,
|
||||||
|
'message-html': htmlContent,
|
||||||
|
headers: {
|
||||||
|
'List-Unsubscribe': `<mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error(
|
||||||
|
`Failed to send to ${recipient}: ${response.status} ${error}`
|
||||||
|
);
|
||||||
|
failCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send to ${recipient}:`, error);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`${subject} email: ${successCount} sent, ${failCount} failed out of ${recipients.length} recipients`
|
||||||
|
);
|
||||||
|
|
||||||
|
return successCount > 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user