Skip to content
Draft
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
41 changes: 41 additions & 0 deletions freelance-assistant/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
42 changes: 42 additions & 0 deletions freelance-assistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Freelance Assistant

A Next.js + TypeScript app to scrape jobs, generate proposals with OpenAI, apply, and track follow-ups.

## Setup

1. Copy env template

```bash
cp .env.local.example .env.local
```

2. Fill in `.env.local` values
- Firebase client (web app) keys
- Firebase Admin credentials (Service Account)
- OpenAI API key
- Optional OAuth creds for Upwork/Freelancer and Apify token

3. Firebase
- Create a Firebase project
- Enable Authentication (Email/Password and Google)
- Create a Web App to obtain client config
- Create a Service Account key and paste into env

4. Install and run

```bash
npm i
npm run dev
```

Open http://localhost:3000

## Features
- Email/Google sign-in
- Scrape jobs from a URL (basic, extend selectors for platforms)
- Generate proposals via OpenAI
- Save proposals, update status, set follow-up dates

## Notes
- Upwork/Freelancer APIs are not yet wired. Add OAuth at `src/app/api/oauth/*` (stubs TBD) and job/proposal endpoints once credentials and scopes are confirmed.
- Scraping public pages may violate platform ToS. Prefer official APIs.
16 changes: 16 additions & 0 deletions freelance-assistant/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
baseDirectory: __dirname,
});

const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];

export default eslintConfig;
7 changes: 7 additions & 0 deletions freelance-assistant/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
};

export default nextConfig;
36 changes: 36 additions & 0 deletions freelance-assistant/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "freelance-assistant",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"apify-client": "^2.13.0",
"axios": "^1.11.0",
"cheerio": "^1.1.2",
"date-fns": "^4.1.0",
"firebase": "^12.1.0",
"firebase-admin": "^13.4.0",
"next": "15.4.6",
"openai": "^5.12.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"uuid": "^11.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
5 changes: 5 additions & 0 deletions freelance-assistant/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};

export default config;
1 change: 1 addition & 0 deletions freelance-assistant/public/file.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions freelance-assistant/public/globe.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions freelance-assistant/public/next.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions freelance-assistant/public/vercel.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions freelance-assistant/public/window.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions freelance-assistant/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { FormEvent, useEffect, useState } from 'react';
import { GoogleAuthProvider, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';

export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);

useEffect(() => {
(async () => {
const { getEnv } = await import('@/lib/env');
const env = getEnv();
setIsReady(env.isConfigured);
})();
}, []);

async function handleEmailSignUp(e: FormEvent) {
e.preventDefault();
setError(null);
try {
const { getFirebaseClientServices } = await import('@/lib/firebase-client');
const { auth } = getFirebaseClientServices();
await createUserWithEmailAndPassword(auth, email, password);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Sign up failed';
setError(message);
}
}

async function handleEmailSignIn(e: FormEvent) {
e.preventDefault();
setError(null);
try {
const { getFirebaseClientServices } = await import('@/lib/firebase-client');
const { auth } = getFirebaseClientServices();
await signInWithEmailAndPassword(auth, email, password);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Sign in failed';
setError(message);
}
}

async function handleGoogle() {
const { getFirebaseClientServices } = await import('@/lib/firebase-client');
const { auth } = getFirebaseClientServices();
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
}

if (!isReady) {
return (
<main className="mx-auto max-w-md p-6">
<p className="text-sm text-gray-600">App not configured. Add environment variables to enable authentication.</p>
</main>
);
}

return (
<main className="mx-auto max-w-md p-6 space-y-6">
<h1 className="text-2xl font-semibold">Login / Sign up</h1>
<button className="w-full border rounded py-2" onClick={handleGoogle}>Continue with Google</button>
<div className="h-px bg-gray-200" />
<form className="space-y-3" onSubmit={handleEmailSignIn}>
<input className="w-full border rounded px-3 py-2" placeholder="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input className="w-full border rounded px-3 py-2" placeholder="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<div className="flex gap-2">
<button className="flex-1 bg-blue-600 text-white rounded py-2" type="submit">Sign in</button>
<button className="flex-1 border rounded py-2" onClick={handleEmailSignUp} type="button">Sign up</button>
</div>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
</main>
);
}
28 changes: 28 additions & 0 deletions freelance-assistant/src/app/api/proposal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { generateProposal } from '@/lib/openai';

const BodySchema = z.object({
freelancerType: z.string().min(1),
jobTitle: z.string().min(1),
jobDescription: z.string().min(1),
budget: z.string().optional(),
freelancerProfileSummary: z.string().optional(),
preferredRate: z.string().optional(),
});

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 });
}

const text = await generateProposal(parsed.data);
return NextResponse.json({ proposal: text });
} catch (error: unknown) {
console.error('Proposal error', error);
return NextResponse.json({ error: 'Failed to generate proposal' }, { status: 500 });
}
}
20 changes: 20 additions & 0 deletions freelance-assistant/src/app/api/proposals/list/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { getFirebaseAdmin } from '@/lib/firebase-admin';
import { verifyAuthToken } from '@/lib/auth';

export async function GET(req: NextRequest) {
const authz = req.headers.get('authorization') || undefined;
const decoded = await verifyAuthToken(authz);
if (!decoded) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const { firestore } = getFirebaseAdmin();
const snap = await firestore
.collection('proposals')
.where('uid', '==', decoded.uid)
.orderBy('createdAt', 'desc')
.limit(100)
.get();

const items = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
return NextResponse.json({ proposals: items });
}
42 changes: 42 additions & 0 deletions freelance-assistant/src/app/api/proposals/save/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getFirebaseAdmin } from '@/lib/firebase-admin';
import { verifyAuthToken } from '@/lib/auth';

const BodySchema = z.object({
jobTitle: z.string().min(1),
jobUrl: z.string().url().optional(),
source: z.string().optional(),
proposal: z.string().min(1),
status: z.enum(['draft','sent','pending','accepted','rejected']).default('draft').optional(),
followUpAt: z.string().datetime().optional(),
});

export async function POST(req: NextRequest) {
const authz = req.headers.get('authorization') || undefined;
const decoded = await verifyAuthToken(authz);
if (!decoded) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const body = await req.json();
const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 });
}

const { firestore } = getFirebaseAdmin();
const now = new Date();
const doc = {
uid: decoded.uid,
jobTitle: parsed.data.jobTitle,
jobUrl: parsed.data.jobUrl || null,
source: parsed.data.source || null,
proposal: parsed.data.proposal,
status: parsed.data.status || 'draft',
followUpAt: parsed.data.followUpAt || null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};

const ref = await firestore.collection('proposals').add(doc);
return NextResponse.json({ id: ref.id, ...doc });
}
43 changes: 43 additions & 0 deletions freelance-assistant/src/app/api/proposals/update/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getFirebaseAdmin } from '@/lib/firebase-admin';
import { verifyAuthToken } from '@/lib/auth';
import type { firestore as AdminFirestore } from 'firebase-admin';

const BodySchema = z.object({
id: z.string().min(1),
status: z.enum(['draft','sent','pending','accepted','rejected']).optional(),
followUpAt: z.string().datetime().nullable().optional(),
});

type ProposalUpdate = {
updatedAt: string;
status?: 'draft' | 'sent' | 'pending' | 'accepted' | 'rejected';
followUpAt?: string | null;
};

export async function POST(req: NextRequest) {
const authz = req.headers.get('authorization') || undefined;
const decoded = await verifyAuthToken(authz);
if (!decoded) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const body = await req.json();
const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 });
}

const { firestore } = getFirebaseAdmin();
const ref = firestore.collection('proposals').doc(parsed.data.id);
const doc = await ref.get();
if (!doc.exists || doc.data()?.uid !== decoded.uid) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

const update: ProposalUpdate = { updatedAt: new Date().toISOString() };
if (parsed.data.status) update.status = parsed.data.status;
if ('followUpAt' in parsed.data) update.followUpAt = parsed.data.followUpAt ?? null;

await ref.update(update as AdminFirestore.UpdateData<AdminFirestore.DocumentData>);
return NextResponse.json({ ok: true });
}
Loading