Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
385 changes: 385 additions & 0 deletions PRODUCT_OVERVIEW.md

Large diffs are not rendered by default.

443 changes: 443 additions & 0 deletions SEO_REPORT.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions amplify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ frontend:
- npx prisma generate
build:
commands:
- echo "GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" >> .env
- echo "GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET" >> .env
- echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" >> .env
- echo "NEXTAUTH_URL=$NEXTAUTH_URL" >> .env
- echo "STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY" >> .env
- echo "STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET" >> .env
- echo "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" >> .env
- echo "NEXT_PUBLIC_WEBSITE_URL=$NEXT_PUBLIC_WEBSITE_URL" >> .env
- echo "DATABASE_URL=$DATABASE_URL" >> .env
- echo "MUX_TOKEN_ID=$MUX_TOKEN_ID" >> .env
- echo "MUX_TOKEN_SECRET=$MUX_TOKEN_SECRET" >> .env
- npm run build
artifacts:
baseDirectory: .next
Expand All @@ -16,3 +27,41 @@ frontend:
paths:
- node_modules/**/*
- .next/cache/**/*
customHeaders:
- pattern: '/api/webhook'
headers:
- key: 'Cache-Control'
value: 'no-store, no-cache, must-revalidate'
- key: 'Pragma'
value: 'no-cache'
- pattern: '/api/**/*'
headers:
- key: 'Cache-Control'
value: 'no-store'
- pattern: '**/*'
headers:
- key: 'Strict-Transport-Security'
value: 'max-age=31536000; includeSubDomains'
- key: 'X-Frame-Options'
value: 'SAMEORIGIN'
- key: 'X-XSS-Protection'
value: '1; mode=block'
- key: 'X-Content-Type-Options'
value: 'nosniff'
customRules:
- pattern: '/api/webhook'
target: '/api/webhook'
status: '200'
condition: 'if(method == "POST" || method == "OPTIONS")'
- pattern: 'ALL'
target: '/index.html'
status: '404-200'
condition: >
if(
!path.matches("\\.well-known(/.*)?") &&
!path.matches("^/.*/static/.*") &&
!path.matches("^/static/.*") &&
!path.matches("^/api/.*") &&
!path.matches("^/_next/.*") &&
!path.matches("^/.*\\.(js|css|png|jpg|jpeg|gif|svg|ico|json)")
)
45 changes: 41 additions & 4 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { withNextVideo } from "next-video/process";

/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true, // Required for Amplify deployment
unoptimized: true,
remotePatterns: [
{
protocol: "https",
Expand All @@ -22,9 +20,48 @@ const nextConfig = {
},
optimizeFonts: true,
output: "standalone",
trailingSlash: true,
experimental: {
esmExternals: "loose",
serverComponentsExternalPackages: ["@prisma/client", "bcrypt"],
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, stripe-signature',
},
],
},
];
},
async rewrites() {
return {
beforeFiles: [
{
source: '/api/webhook',
destination: '/api/webhook',
has: [
{
type: 'header',
key: 'stripe-signature',
},
],
},
],
};
},
};

export default withNextVideo(nextConfig);
export default nextConfig;
27 changes: 14 additions & 13 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-1.0.x"]
}

datasource db {
Expand Down Expand Up @@ -39,18 +40,18 @@ model Session {
}

model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
stripeCustomerId String? @unique
isSubscribed Boolean @default(false)
hasUsedFreeTrial Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
stripeCustomerId String? @unique
isSubscribed Boolean @default(false)
hasUsedFreeTrial Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]

@@map("users")
}
Expand Down
Binary file added public/changelog/advanced-list.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/alert.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/breathing-intervetion.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/breathing-setting.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/chart-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/confirmation-prompt.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/confirmation.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/pin.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/pro.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/changelog/theme.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
User-agent: *
Allow: /
Sitemap: https://focusmode.app/sitemap.xml

User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: ClaudeBot
Allow: /
8 changes: 8 additions & 0 deletions rewrites.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/api/<*>",
"destination": "/api/<*>"
}
]
}
28 changes: 24 additions & 4 deletions src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,43 @@ export const authOptions: NextAuthOptions = {
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name:
process.env.NODE_ENV === "production"
? `__Secure-next-auth.session-token`
: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
domain:
process.env.NODE_ENV === "production" ? ".focusmode.app" : undefined,
},
},
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
// Fetch the latest user data from the database
}

// For lifetime subscription: only check DB if not yet subscribed
// Once true, it stays true forever - no need to re-check
if (token.id && !token.isSubscribed) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
where: { id: token.id as string },
select: { stripeCustomerId: true, isSubscribed: true },
});
if (dbUser) {
token.stripeCustomerId = dbUser.stripeCustomerId ?? undefined;
token.isSubscribed = dbUser.isSubscribed;
} else {
token.isSubscribed = false;
}
}

return token;
},
async session({ session, token }) {
Expand Down
47 changes: 43 additions & 4 deletions src/app/api/user/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { decode } from "next-auth/jwt";
import { prisma } from "@/lib/prisma";
import { authOptions } from "../auth/[...nextauth]/route";

export { PATCH as PATCH } from "./free-trial";

export async function GET(req: NextRequest) {
let userId: string | null = null;

// Try cookie-based auth first (for web app)
const session = await getServerSession(authOptions);
if (session?.user?.id) {
userId = session.user.id;
}

// Fallback to Bearer token auth (for Chrome extension)
if (!userId) {
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
try {
const decoded = await decode({
token,
secret: process.env.NEXTAUTH_SECRET!,
});

if (decoded?.id) {
userId = decoded.id as string;
}
} catch (error) {
console.error("JWT decode failed:", error);
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
}
}

if (!session || !session.user) {
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: {
id: true,
name: true,
Expand All @@ -27,10 +55,21 @@ export async function GET(req: NextRequest) {
});

if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

return NextResponse.json({ user }, { status: 200 });
return NextResponse.json(
{ user },
{
status: 200,
headers: {
"Cache-Control":
"no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
);
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ import { stripe } from "@/lib/stripe";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(req: NextRequest) {
console.log("Webhook received:", req.url);
console.log(
"Headers:",
JSON.stringify(Object.fromEntries(req.headers.entries()), null, 2)
);

const body = await req.text();
const sig = req.headers.get("stripe-signature");

if (!sig || !webhookSecret) {
console.error("Missing signature or webhook secret");
return NextResponse.json(
{ error: "Missing signature or webhook secret" },
{ status: 400 }
Expand All @@ -20,6 +27,7 @@ export async function POST(req: NextRequest) {

try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
console.log("Event constructed successfully:", event.type);
} catch (err: unknown) {
const error = err as Stripe.errors.StripeError;
console.error(`Webhook Error: ${error.message}`);
Expand Down
Loading