Skip to content
Closed
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
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# Get your API key from: https://console.cloud.google.com/
# Required APIs: Places API, Geocoding API
# Without this: Mock competitor data will be used
GOOGLE_PLACES_API_KEY=
# Note: Current server code expects GOOGLE_API_KEY
GOOGLE_API_KEY=

# -----------------------------------------------------------------------------
# OpenAI API (for AI-powered analysis)
Expand Down Expand Up @@ -82,9 +83,21 @@ NODE_ENV=development
ALLOWED_ORIGINS=

# Secret for triggering cron jobs (e.g. daily cleanup)
# Must match header x-cron-secret in requests
# Must match either:
# - header x-cron-secret
# - or Authorization: Bearer <CRON_SECRET> (Vercel Cron default)
CRON_SECRET=

# Public canonical app URL used by generated sitemap/robots
# Example: https://competitorwatcher.pt
PUBLIC_APP_URL=

# Runtime controls
# Set to true only for one-off controlled migration runs in serverless
RUN_MIGRATIONS_ON_BOOT=false
# Set to true if hosting a long-running Node process but using external cron triggers
DISABLE_INTERNAL_SCHEDULER=false

# -----------------------------------------------------------------------------
# Stripe Payment Integration (for Pro subscriptions)
# -----------------------------------------------------------------------------
Expand Down
5 changes: 1 addition & 4 deletions .github/workflows/cleanup-users.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
name: User Cleanup Scheduler

on:
schedule:
# Runs at 04:00 daily
- cron: '0 4 * * *'
workflow_dispatch: # Allows manual triggering
workflow_dispatch: # Manual fallback (Vercel Cron handles scheduled runs)

jobs:
cleanup-users:
Expand Down
44 changes: 0 additions & 44 deletions .github/workflows/deploy.yml

This file was deleted.

18 changes: 0 additions & 18 deletions .github/workflows/keep-alive.yml

This file was deleted.

5 changes: 1 addition & 4 deletions .github/workflows/scheduler.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
name: Weekly Report Scheduler

on:
schedule:
# Runs at 06:00 every Monday
- cron: '0 6 * * 1'
workflow_dispatch: # Allows manual triggering
workflow_dispatch: # Manual fallback (Vercel Cron handles scheduled runs)

jobs:
trigger-reports:
Expand Down
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ For real competitor data, configure Google Places API:

```bash
# .env
GOOGLE_PLACES_API_KEY=your_api_key_here
GOOGLE_API_KEY=your_api_key_here
```

See [SETUP_GUIDE.md](./SETUP_GUIDE.md) for detailed setup instructions.
Expand All @@ -97,7 +97,7 @@ See [SETUP_GUIDE.md](./SETUP_GUIDE.md) for detailed setup instructions.

```bash
# Google Places API (for real competitor data)
GOOGLE_PLACES_API_KEY=
GOOGLE_API_KEY=

# OpenAI API (for AI-powered analysis)
OPENAI_API_KEY=
Expand All @@ -116,10 +116,70 @@ SESSION_SECRET=

---

## ▲ Deploying On Vercel

This repository is now wired for Vercel with:

- `vercel.json` (SPA rewrite + cron jobs + Vite output directory)
- `api/[...path].ts` (Express API as a Vercel Function)
- `build:client` script for static frontend output
- `build:vercel` guard that blocks production deploys from branches other than `main`

### 1. Import the repo in Vercel

1. Open Vercel and import this GitHub repository.
2. Keep the default Node.js runtime.
3. No extra build settings are required (they come from `vercel.json`).

### 2. Add environment variables in Vercel

At minimum, set these in Project Settings > Environment Variables:

- `DATABASE_URL`
- `SESSION_SECRET`
- `ALLOWED_ORIGINS` (your production domain(s), comma-separated)
- `GOOGLE_API_KEY`
- `OPENAI_API_KEY` (if AI reports enabled)
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `GOOGLE_CALLBACK_URL` (must be `https://<your-domain>/api/auth/google/callback`)
- `CRON_SECRET`
- `PUBLIC_APP_URL` (for sitemap/robots canonical URLs)
- Stripe vars if billing is enabled:
- `STRIPE_SECRET_KEY`
- `STRIPE_PUBLISHABLE_KEY`
- `STRIPE_PRICE_ID`
- `STRIPE_WEBHOOK_SECRET`

### 3. Update third-party callbacks/webhooks

- Google OAuth redirect URI:
- `https://<your-domain>/api/auth/google/callback`
- Stripe webhook endpoint:
- `https://<your-domain>/api/webhook`

### 4. Cron behavior after migration

- Vercel Cron is configured in `vercel.json` for:
- weekly report trigger (`/api/cron/trigger-reports`)
- daily cleanup (`/api/cron/cleanup-users`)
- Legacy GitHub scheduler workflows were kept as manual fallback only.

### 5. Cutover checklist

1. Deploy to Vercel.
2. Point your custom domain to Vercel.
3. Update OAuth/webhook URLs to the new domain.
4. Verify login, report generation, Stripe webhook, and cron execution.

---

## 📁 Project Structure

```
competitor-watcher/
├── api/ # Vercel serverless entrypoint
│ └── [...path].ts
├── client/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
Expand All @@ -129,7 +189,8 @@ competitor-watcher/
│ │ └── i18n/ # Internationalization
├── server/ # Express backend
│ ├── auth.ts # Authentication logic
│ ├── routes.ts # API endpoints
│ ├── bootstrap.ts # Shared app bootstrap (standalone + serverless)
│ ├── routes/ # API route modules
│ ├── storage.ts # Data persistence layer
│ ├── ai.ts # AI analysis engine
│ └── index.ts # Server entry point
Expand Down
22 changes: 22 additions & 0 deletions api/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createConfiguredServer } from "../server/bootstrap";
import type { Express } from "express";

let appPromise: Promise<Express> | null = null;

async function getApp() {
if (!appPromise) {
appPromise = createConfiguredServer({
runtime: "serverless",
runSeed: false,
runMigrations: process.env.RUN_MIGRATIONS_ON_BOOT === "true",
startScheduler: false,
}).then(({ app }) => app);
}

return appPromise;
}

export default async function handler(req: any, res: any) {
const app = await getApp();
return app(req, res);
}
38 changes: 29 additions & 9 deletions client/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export default function LandingPage() {
// Geolocation state
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [manualCoordinates, setManualCoordinates] = useState<{ lat: number; lng: number } | null>(null);
const [isUsingCurrentLocation, setIsUsingCurrentLocation] = useState(false);

const formatCoordinateFallback = useCallback((latitude: number, longitude: number) => {
return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`;
}, []);

useEffect(() => {
const handleScroll = () => {
Expand Down Expand Up @@ -88,6 +93,7 @@ export default function LandingPage() {
lat: latitude,
lng: longitude
});
setIsUsingCurrentLocation(true);

try {
// Attempt to reverse geocode
Expand All @@ -99,26 +105,31 @@ export default function LandingPage() {

if (response.ok) {
const data = await response.json();
if (data.address) {
form.setValue('address', data.address);
const detectedAddress = typeof data.address === "string" ? data.address.trim() : "";
const isPlaceholderAddress = /^current location/i.test(detectedAddress);

if (detectedAddress && !isPlaceholderAddress) {
form.setValue('address', detectedAddress);
return;
}
}
// Fallback if failed
form.setValue('address', "Current Location");
// Fallback if reverse geocoding is unavailable
form.setValue('address', formatCoordinateFallback(latitude, longitude));
} catch (e) {
// Fallback
form.setValue('address', "Current Location");
// Fallback if network request fails
form.setValue('address', formatCoordinateFallback(latitude, longitude));
}
},
(error) => {
setIsGettingLocation(false);
setIsUsingCurrentLocation(false);
setManualCoordinates(null);
let errorMessage = "Could not get your location";
if (error.code === 1) errorMessage = "Location permission denied";
setSearchError(errorMessage);
}
);
}, [form]);
}, [form, formatCoordinateFallback]);

const onSearchSubmit = async (data: SearchFormValues) => {
// Check if user has already generated a free report (only for guests)
Expand All @@ -142,8 +153,8 @@ export default function LandingPage() {
language: t('common.language', { defaultValue: 'en' })
};

// If using manual coordinates (Current Location), inject them
if (manualCoordinates && data.address === "Current Location") {
// If the user selected the location icon, always prefer exact coordinates.
if (manualCoordinates && isUsingCurrentLocation) {
payload.latitude = manualCoordinates.lat;
payload.longitude = manualCoordinates.lng;
}
Expand Down Expand Up @@ -303,6 +314,15 @@ export default function LandingPage() {
placeholder="Rua de Belém 84-92, 1300-085 Lisboa"
className={`w-full h-12 rounded-xl border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm focus:ring-2 focus:ring-indigo-500 transition-all pl-4 text-base ${isGettingLocation ? 'pr-12' : 'pr-12'}`}
data-testid="input-quick-search-address"
onChange={(event) => {
field.onChange(event);
if (manualCoordinates) {
setManualCoordinates(null);
}
if (isUsingCurrentLocation) {
setIsUsingCurrentLocation(false);
}
}}
/>
</FormControl>
<button
Expand Down
Loading