Skip to content
Merged
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
4 changes: 0 additions & 4 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ jobs:
pnpm install
pnpm exec playwright install --with-deps

# SAFE DB SETUP: Only generate the client, DO NOT push schema
- name: Setup Database Client
run: |
pnpm prisma generate
env:
# We don't need the URL for generate, but good to keep if needed later
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
DIRECT_URL: ${{ secrets.TEST_DIRECT_URL }}

Expand All @@ -42,11 +40,9 @@ jobs:
- name: Run Playwright Tests
run: pnpm exec playwright test
env:
# These now point to PRODUCTION DB
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
DIRECT_URL: ${{ secrets.TEST_DIRECT_URL }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
# Point to localhost because we are running the built app in the runner
AUTH_URL: "http://localhost:3000"
AUTH_TRUST_HOST: "true"
RESEND_API_KEY: "re_mock_key"
Expand Down
10 changes: 1 addition & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,4 @@ The system is designed for serverless scalability:

* **Hosting:** Deployed on **Vercel**, leveraging Edge Networks for global content delivery and serverless function execution.
* **Edge Generation:** Dynamic asset generation (Favicons, Open Graph images) is handled at the edge using `next/og`, reducing bundle size and ensuring brand consistency.
* **Email Infrastructure:** Transactional emails are routed through **Resend**, ensuring high deliverability for critical billing alerts.


## Operational Features

* **Adaptive Glassmorphism:** The UI features a sophisticated dark mode with ambient background lighting that reacts to screen size, providing a premium depth of field.
* **Live Currency Conversion:** The system fetches and caches live exchange rates, allowing users to add a subscription in JPY and see the impact in USD instantly.
* **Privacy-First Architecture:** Subscription data is scoped strictly to the authenticated user via multi-tenant database logic, ensuring no data bleed between accounts.
* **Dynamic 404 Handling:** A custom error routing system that captures invalid paths and presents a branded recovery interface, overlaying standard layout elements.
* **Email Infrastructure:** Transactional emails are routed through **Resend**, ensuring high deliverability for critical billing alerts.
14 changes: 1 addition & 13 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,35 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
// 1. 👇 The Critical Fix: Tell Playwright to ONLY look here
testDir: "./tests/e2e",

// 2. Run tests in files in parallel
fullyParallel: true,

// 3. Fail the build on CI if you accidentally left test.only in the source code.
forbidOnly: !!process.env.CI,

// 4. Retry on CI only
retries: process.env.CI ? 2 : 0,

// 5. Opt out of parallel tests on CI.
workers: process.env.CI ? 1 : undefined,

// 6. Reporter to use.
reporter: "html",

use: {
// 7. Base URL for your app (matches your pnpm dev port)
baseURL: "http://localhost:3000",

// 8. Collect trace when retrying the failed test.
trace: "on-first-retry",
},

// 9. Configure projects for major browsers
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],

// 10. Run your local dev server before starting the tests
// This ensures your app is running at localhost:3000
webServer: {
// If we are in CI, run the production server. Locally, run dev.
command: process.env.CI ? "pnpm start" : "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120000, // Give it 2 minutes to boot up in the cloud
timeout: 120000,
},
});
8 changes: 0 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ datasource db {
directUrl = env("DIRECT_URL")
}

// --- Auth.js v5 Models ---

model User {
id String @id @default(cuid())
name String?
Expand All @@ -18,11 +16,9 @@ model User {
image String?
password String?

// 2FA Fields
twoFactorSecret String?
isTwoFactorEnabled Boolean @default(false)

// Relations
accounts Account[]
sessions Session[]
subscriptions Subscription[]
Expand Down Expand Up @@ -77,8 +73,6 @@ model VerificationToken {
@@id([identifier, token])
}

// --- SubTrack Business Logic ---

enum BillingFrequency {
MONTHLY
YEARLY
Expand Down Expand Up @@ -143,8 +137,6 @@ model Subscription {
@@index([userId])
}

// --- Collaborative Models ---

model Circle {
id String @id @default(cuid())
name String
Expand Down
4 changes: 2 additions & 2 deletions src/actions/onboarding-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { onboardingSchema } from "@/lib/validations/settings";

export async function finishOnboarding(data: { currency: string; notifications: boolean }) {
export async function finishOnboarding(data: { currency: string; notifications: boolean; twoFactorEnabled: boolean }) {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };

// Strict Validation
const validated = onboardingSchema.safeParse(data);
if (!validated.success) {
return { success: false, error: "Invalid configuration data provided." };
Expand All @@ -21,6 +20,7 @@ export async function finishOnboarding(data: { currency: string; notifications:
data: {
preferredCurrency: validated.data.currency,
emailNotifications: validated.data.notifications,
isTwoFactorEnabled: validated.data.twoFactorEnabled,
hasCompletedOnboarding: true,
},
});
Expand Down
7 changes: 4 additions & 3 deletions src/actions/settings-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ export async function verifyAndEnableTwoFactor(token: string) {
return { success: false, message: "2FA initialization missing." };
}

const result = await verify({
const isValid = verify({
token,
secret: user.twoFactorSecret,
});

if (!result.valid)
if (!isValid) {
return { success: false, message: "Invalid synchronization code." };
}

await prisma.user.update({
where: { id: user.id },
Expand All @@ -124,4 +125,4 @@ export async function disableTwoFactor() {

revalidatePath("/settings");
return { success: true };
}
}
2 changes: 0 additions & 2 deletions src/actions/support-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export async function sendSupportTicket(formData: FormData) {

try {
await resend.emails.send({
// "onboarding@resend.dev" allows testing without a verified domain.
// Once you verify "subvantage.com" in Resend, change this to "support@subvantage.com"
from: "SubVantage Support <onboarding@resend.dev>",
to: "iannmacabulos@gmail.com",
subject: `[SubVantage Support] ${subject}`,
Expand Down
3 changes: 1 addition & 2 deletions src/actions/user-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export async function updateUserSettings(data: UpdateSettingsData) {
return { success: false, message: "Unauthorized" };
}

// 👇 Fix: Access property directly from the object
const { preferredCurrency } = data;

try {
Expand All @@ -25,7 +24,7 @@ export async function updateUserSettings(data: UpdateSettingsData) {

revalidatePath("/dashboard");
revalidatePath("/settings");
revalidatePath("/archive"); // Ensure currency updates everywhere
revalidatePath("/archive");

return { success: true, message: "Settings updated successfully" };
} catch (error) {
Expand Down
1 change: 0 additions & 1 deletion src/app/(dashboard)/archive/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export default async function ArchivePage() {
const session = await auth();
if (!session?.user?.id) redirect("/");

// ✅ PARALLEL FETCH: Fetch User (for currency) and Archived Subs together
const [user, rawSubs] = await Promise.all([
prisma.user.findUnique({
where: { id: session.user.id },
Expand Down
15 changes: 7 additions & 8 deletions src/app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { DashboardHeader } from "@/components/dashboard/DashboardHeader";
import { StatsGrid } from "@/components/dashboard/DashboardWidgets";
import { UpcomingBills } from "@/components/dashboard/UpcomingBills";
import { SubscriptionCarousel } from "@/components/dashboard/SubscriptionCarousel";
import { DashboardClient } from "@/components/dashboard/DashboardClient";
import { InsightsCard } from "@/components/dashboard/Insights";
import { getExchangeRates } from "@/lib/currency-helper";
import { processSubscriptionData } from "@/lib/calculations";
Expand Down Expand Up @@ -88,13 +88,12 @@ export default async function DashboardPage() {
</div>
</div>

<div className="w-full pb-8">
<SubscriptionCarousel
data={activeSubs}
currency={baseCurrency}
rates={rates}
/>
</div>
<DashboardClient
initialSubs={activeSubs}
baseCurrency={baseCurrency}
rates={rates}
/>

</div>
);
}
1 change: 0 additions & 1 deletion src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export default async function DashboardLayout({
redirect("/auth/login");
}

// THE FOOLPROOF TRAP: Instead of redirecting, block the UI with the lock screen
if ((session.user as any).is2faVerified === false) {
return <Verify2FAScreen />;
}
Expand Down
10 changes: 4 additions & 6 deletions src/app/(dashboard)/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export default function DashboardLoading() {
{/* 1. Header Skeleton */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" /> {/* Title */}
<Skeleton className="h-4 w-64" /> {/* Subtitle */}
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-32" /> {/* Button */}
<Skeleton className="h-10 w-32" />
</div>
</div>

Expand All @@ -31,18 +31,16 @@ export default function DashboardLoading() {
))}
</div>

{/* 3. Main Content / Table Skeleton */}
<div className="rounded-xl border border-border/60 bg-card/50 p-6 space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 justify-between">
<Skeleton className="h-10 w-full sm:w-64" /> {/* Search */}
<Skeleton className="h-10 w-full sm:w-64" />
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</div>

{/* List Items */}
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 rounded-lg border border-border/40">
Expand Down
1 change: 0 additions & 1 deletion src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export default async function SettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/");

// Fetch fresh user data from DB
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
Expand Down
12 changes: 0 additions & 12 deletions src/app/(dashboard)/subscriptions/[id]/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,32 @@ export default function SubscriptionDetailLoading() {
return (
<div className="max-w-5xl mx-auto space-y-8 animate-in fade-in duration-500">

{/* 🔙 Navigation Skeleton */}
<div className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4 text-muted-foreground/50" />
<Skeleton className="h-4 w-32" />
</div>

{/* ✨ Hero Header Skeleton */}
<div className="rounded-3xl border border-border/40 bg-secondary/30 p-8 shadow-sm">
<div className="flex flex-col md:flex-row gap-6 md:items-center justify-between">
<div className="flex items-center gap-4 md:gap-6">
{/* Icon */}
<Skeleton className="h-16 w-16 md:h-24 md:w-24 rounded-2xl" />
<div className="min-w-0 flex-1 space-y-3">
{/* Title & Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
{/* Category */}
<Skeleton className="h-4 w-24" />
</div>
</div>

<div className="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
{/* Cost Display */}
<div className="flex flex-col items-start md:items-end space-y-2">
<Skeleton className="h-4 w-12" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-6 w-8" />
</div>
</div>
{/* Actions */}
<div className="flex gap-1 mt-2">
<Skeleton className="h-9 w-9 rounded-md" />
<Skeleton className="h-9 w-9 rounded-md" />
Expand All @@ -47,10 +40,8 @@ export default function SubscriptionDetailLoading() {
</div>
</div>

{/* 📊 The Grid Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">

{/* Current Cycle Card */}
<div className="col-span-1 md:col-span-2 rounded-xl border border-border/50 bg-card/50 p-6 space-y-4">
<Skeleton className="h-4 w-32" />
<div className="flex justify-between items-end">
Expand All @@ -67,21 +58,18 @@ export default function SubscriptionDetailLoading() {
</div>
</div>

{/* Yearly Cost Card */}
<div className="rounded-xl border border-border/50 bg-card/50 p-6 space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-3 w-40" />
</div>

{/* Lifetime Spend Card */}
<div className="rounded-xl border border-border/50 bg-card/50 p-6 space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-3 w-40" />
</div>

{/* Details Card */}
<div className="col-span-1 md:col-span-2 rounded-xl border border-border/50 bg-card/50 p-6 space-y-6">
<Skeleton className="h-4 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-6 gap-x-12">
Expand Down
2 changes: 0 additions & 2 deletions src/app/(dashboard)/subscriptions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ type Props = {
params: Promise<{ id: string }>;
};

// 👇 REPLACED: Static metadata with Dynamic Metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const session = await auth();
const { id } = await params;
Expand All @@ -18,7 +17,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: "Details" };
}

// Fetch just enough data to get the title
const sub = await prisma.subscription.findUnique({
where: {
id: id,
Expand Down
2 changes: 0 additions & 2 deletions src/app/(dashboard)/subscriptions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export default async function SubscriptionsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/");

// ✅ PARALLEL FETCH: Fetch User and Subscriptions at the same time
const [user, rawSubs] = await Promise.all([
prisma.user.findUnique({
where: { id: session.user.id },
Expand All @@ -30,7 +29,6 @@ export default async function SubscriptionsPage() {

const baseCurrency = user?.preferredCurrency || "USD";

// Fetch rates after we know the currency (this is fast)
const rates = await getExchangeRates(baseCurrency);

const subs = rawSubs.map(sub => ({
Expand Down
Loading
Loading