├── api
└── index.ts
├── favicon.svg
├── src
├── routes
│ ├── stats.ts
│ ├── track.ts
│ └── widget.ts
├── middleware
│ └── cors.ts
├── lib
│ ├── prisma.ts
│ └── constants.ts
├── server.ts
├── types
│ └── index.ts
├── app.ts
└── controllers
│ ├── trackController.ts
│ ├── statsController.ts
│ └── widgetController.ts
├── .gitignore
├── vercel.json
├── .env.example
├── tsconfig.json
├── prisma
└── schema.prisma
├── LICENSE
├── eslint.config.js
├── package.json
├── CONTRIBUTING.md
└── README.md
/api/index.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from "../src/app.js";
2 |
3 | // Create the Express app
4 | const app = createApp();
5 |
6 | // Export for Vercel serverless function
7 | export default app;
8 |
--------------------------------------------------------------------------------
/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/routes/stats.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { statsController } from "../controllers/statsController.js";
3 |
4 | const router = Router();
5 |
6 | router.get("/", statsController);
7 |
8 | export { router as statsRoute };
9 |
--------------------------------------------------------------------------------
/src/routes/track.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { trackController } from "../controllers/trackController.js";
3 |
4 | const router = Router();
5 |
6 | router.post("/", trackController);
7 |
8 | export { router as trackRoute };
9 |
--------------------------------------------------------------------------------
/src/routes/widget.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { widgetController } from "../controllers/widgetController.js";
3 |
4 | const router = Router();
5 |
6 | router.get("/", widgetController);
7 |
8 | export { router as widgetRoute };
9 |
--------------------------------------------------------------------------------
/src/middleware/cors.ts:
--------------------------------------------------------------------------------
1 | import cors from "cors";
2 |
3 | export const corsMiddleware = cors({
4 | origin: "*", // Allow all origins for analytics widget embedding
5 | credentials: false, // No credentials needed for analytics
6 | optionsSuccessStatus: 200,
7 | });
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 |
4 | # Environment variables
5 | .env
6 | .env.local
7 |
8 | # Build output
9 | dist/
10 | *.tsbuildinfo
11 |
12 | # Logs
13 | *.log
14 |
15 | # IDE
16 | .vscode/
17 | .idea/
18 |
19 | # OS
20 | .DS_Store
21 |
22 | # Vercel
23 | .vercel
24 |
--------------------------------------------------------------------------------
/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | const globalForPrisma = globalThis as unknown as {
4 | prisma: PrismaClient | undefined;
5 | };
6 |
7 | export const prisma = globalForPrisma.prisma ?? new PrismaClient();
8 |
9 | if (process.env.NODE_ENV !== "production") {
10 | globalForPrisma.prisma = prisma;
11 | }
12 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "buildCommand": "npm run build",
4 | "builds": [
5 | {
6 | "src": "api/index.ts",
7 | "use": "@vercel/node"
8 | }
9 | ],
10 | "routes": [
11 | {
12 | "src": "/(.*)",
13 | "dest": "api/index.ts"
14 | }
15 | ],
16 | "env": {
17 | "NODE_ENV": "production"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from "./app.js";
2 |
3 | const app = createApp();
4 | const port = process.env.PORT || 3210;
5 |
6 | app.listen(port, () => {
7 | console.log(`🚀 here/now API server running on port ${port}`);
8 | console.log(`📊 Widget available at: http://localhost:${port}/widget.js`);
9 | console.log(`💻 API docs: http://localhost:${port}`);
10 | });
11 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface StatsResult {
2 | here: number;
3 | now: number;
4 | domain: string;
5 | path: string;
6 | }
7 |
8 | export interface QueryResult {
9 | here_count: bigint;
10 | now_count: bigint;
11 | }
12 |
13 | export interface TrackingRequest {
14 | domain: string;
15 | path: string;
16 | user_id?: string;
17 | session_id?: string;
18 | }
19 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Database (Supabase)
2 | DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:6543/postgres?pgbouncer=true"
3 | DIRECT_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:5432/postgres"
4 |
5 | # Domain Configuration
6 | ALLOWED_DOMAINS="localhost,yourdomain.com,yourotherdomain.com"
7 |
8 | # API Base URL (auto-detected if not set, hardcoded to HTTPS)
9 | API_BASE_URL="http://localhost:3210"
10 |
11 | # Node Environment
12 | NODE_ENV="development"
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ESNext",
5 | "moduleResolution": "bundler",
6 | "outDir": "./dist",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "allowSyntheticDefaultImports": true,
11 | "skipLibCheck": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "resolveJsonModule": true,
14 | "sourceMap": true,
15 | "incremental": true,
16 | "declaration": true,
17 | "removeComments": true
18 | },
19 | "include": ["src/**/*", "api/**/*"],
20 | "exclude": ["node_modules", "dist", "**/*.test.ts"]
21 | }
22 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | directUrl = env("DIRECT_URL")
9 | }
10 |
11 | model PageEvent {
12 | id String @id @default(cuid())
13 | domain String
14 | path String
15 | userId String @map("user_id")
16 | sessionId String @map("session_id")
17 | userAgent String @map("user_agent")
18 | timestamp DateTime @default(now())
19 | metadata Json?
20 | migrationStatus String? @map("migration_status")
21 |
22 | @@index([domain, timestamp])
23 | @@index([domain, userId])
24 | @@index([migrationStatus])
25 | @@map("page_events")
26 | }
27 |
28 | model User {
29 | id String @id @default(cuid())
30 | email String @unique
31 | domain String
32 | reason String
33 | whitelistedAt DateTime? @map("whitelisted_at")
34 | createdAt DateTime @default(now()) @map("created_at")
35 |
36 | @@map("users")
37 | }
38 |
--------------------------------------------------------------------------------
/src/app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { trackRoute } from "./routes/track.js";
3 | import { statsRoute } from "./routes/stats.js";
4 | import { widgetRoute } from "./routes/widget.js";
5 | import { corsMiddleware } from "./middleware/cors.js";
6 |
7 | export const createApp = () => {
8 | const app = express();
9 |
10 | // Middleware
11 | app.use(corsMiddleware);
12 | app.use(express.json());
13 |
14 | // Health check endpoint
15 | app.get("/health", (req, res) => {
16 | res.json({ status: "ok", service: "here-now-api" });
17 | });
18 |
19 | // API routes
20 | app.use("/api/track", trackRoute);
21 | app.use("/api/stats", statsRoute);
22 | app.use("/widget.js", widgetRoute);
23 |
24 | // Root endpoint info
25 | app.get("/", (req, res) => {
26 | res.json({
27 | name: "here/now analytics API",
28 | version: "0.1.0",
29 | endpoints: {
30 | track: "POST /api/track",
31 | stats: "GET /api/stats",
32 | widget: "GET /widget.js",
33 | health: "GET /health",
34 | },
35 | docs: "https://github.com/fredrivett/here-now",
36 | });
37 | });
38 |
39 | return app;
40 | };
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
2 |
3 | Copyright (c) 2025 Jotmake Limited
4 |
5 | This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
6 |
7 | You are free to:
8 | - Share — copy and redistribute the material in any medium or format
9 | - Adapt — remix, transform, and build upon the material
10 |
11 | Under the following terms:
12 | - Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
13 | - NonCommercial — You may not use the material for commercial purposes. This includes offering this software as a paid service or SaaS platform.
14 | - ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
15 |
16 | No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
17 |
18 | To view a copy of this license, visit:
19 | https://creativecommons.org/licenses/by-nc-sa/4.0/
20 |
21 | For commercial licensing, please contact: fred@fredrivett.com
22 |
--------------------------------------------------------------------------------
/src/controllers/trackController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import { prisma } from "../lib/prisma.js";
3 | import { isDomainAllowed } from "../lib/constants.js";
4 | import { v4 as uuidv4 } from "uuid";
5 | import { TrackingRequest } from "../types/index.js";
6 |
7 | export const trackController = async (req: Request, res: Response) => {
8 | try {
9 | const { domain, path, user_id, session_id }: TrackingRequest = req.body;
10 |
11 | // Validate required parameters
12 | if (!domain) {
13 | return res.status(400).json({
14 | error: "Missing required parameter: domain",
15 | });
16 | }
17 |
18 | if (!path) {
19 | return res.status(400).json({
20 | error: "Missing required parameter: path",
21 | });
22 | }
23 |
24 | // Check domain whitelist
25 | if (!isDomainAllowed(domain)) {
26 | return res.status(403).json({
27 | error: "Domain not allowed",
28 | });
29 | }
30 |
31 | const userAgent = req.headers["user-agent"] || "";
32 |
33 | // Insert tracking event
34 | const event = await prisma.pageEvent.create({
35 | data: {
36 | domain,
37 | path,
38 | userId: user_id || uuidv4(),
39 | sessionId: session_id || uuidv4(),
40 | userAgent,
41 | },
42 | });
43 |
44 | res.json({ success: true, event_id: event.id });
45 | } catch (error) {
46 | console.error("Tracking error:", error);
47 | res.status(500).json({
48 | error: "Failed to track event",
49 | });
50 | }
51 | };
52 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import typescript from "@typescript-eslint/eslint-plugin";
3 | import typescriptParser from "@typescript-eslint/parser";
4 |
5 | export default [
6 | // Apply to TypeScript and JavaScript files
7 | {
8 | files: ["**/*.{ts,js}"],
9 | languageOptions: {
10 | parser: typescriptParser,
11 | parserOptions: {
12 | ecmaVersion: "latest",
13 | sourceType: "module",
14 | },
15 | globals: {
16 | console: "readonly",
17 | process: "readonly",
18 | Buffer: "readonly",
19 | __dirname: "readonly",
20 | __filename: "readonly",
21 | global: "readonly",
22 | },
23 | },
24 | plugins: {
25 | "@typescript-eslint": typescript,
26 | },
27 | rules: {
28 | // ESLint recommended rules
29 | ...js.configs.recommended.rules,
30 |
31 | // TypeScript specific rules
32 | "@typescript-eslint/no-unused-vars": [
33 | "error",
34 | {
35 | argsIgnorePattern: "^_",
36 | varsIgnorePattern: "^_",
37 | },
38 | ],
39 | "@typescript-eslint/no-explicit-any": "warn",
40 |
41 | // General code quality
42 | "no-console": "off", // Allow console.log for server logging
43 | "prefer-const": "error",
44 | "no-var": "error",
45 | eqeqeq: ["error", "always"],
46 | curly: "error",
47 |
48 | // Import/export
49 | "no-duplicate-imports": "error",
50 | },
51 | },
52 |
53 | // Ignore certain files
54 | {
55 | ignores: ["node_modules/**", "dist/**", "**/*.js.map", ".env*"],
56 | },
57 | ];
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "here-now",
3 | "version": "0.1.0",
4 | "type": "module",
5 | "description": "A simple, privacy-focused visitor tracking API",
6 | "main": "dist/server.js",
7 | "scripts": {
8 | "dev": "tsx watch src/server.ts",
9 | "dev:vercel": "vercel dev",
10 | "build": "prisma generate && tsc",
11 | "start": "node dist/server.js",
12 | "postinstall": "prisma generate",
13 | "db:generate": "prisma generate",
14 | "db:push": "prisma db push",
15 | "db:migrate": "prisma migrate dev",
16 | "fix": "npm run format && npm run lint:fix",
17 | "lint": "eslint",
18 | "lint:all": "bunx concurrently --success=all -n ESLint,Prettier,TS -c cyan,green,magenta \"bun run lint:quiet\" \"bun run format:check\" \"bun run ts:check\"",
19 | "lint:fix": "eslint --fix --quiet",
20 | "format": "prettier . --write"
21 | },
22 | "dependencies": {
23 | "@prisma/client": "^6.16.0",
24 | "cors": "^2.8.5",
25 | "express": "^4.18.2",
26 | "uuid": "^13.0.0",
27 | "zod": "^4.1.7"
28 | },
29 | "devDependencies": {
30 | "@types/cors": "^2.8.17",
31 | "@types/express": "^4.17.21",
32 | "@types/node": "^20.0.0",
33 | "@types/uuid": "^10.0.0",
34 | "@typescript-eslint/eslint-plugin": "^8.43.0",
35 | "@typescript-eslint/parser": "^8.43.0",
36 | "@vercel/node": "^3.0.0",
37 | "eslint": "^9.35.0",
38 | "prisma": "^6.16.0",
39 | "tsx": "^4.0.0",
40 | "typescript": "^5.3.0"
41 | },
42 | "keywords": [
43 | "analytics",
44 | "visitor-tracking",
45 | "privacy",
46 | "self-hosted"
47 | ],
48 | "author": "Fred Rivett",
49 | "license": "CC-BY-NC-SA-4.0"
50 | }
51 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | // Activity threshold - used for both heartbeat interval and "now" window
2 | export const ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
3 |
4 | // Domain whitelist - configurable via environment variable
5 | function getAllowedDomains(): string[] {
6 | const envDomains = process.env.ALLOWED_DOMAINS;
7 | if (envDomains) {
8 | return envDomains.split(",").map((d) => d.trim());
9 | }
10 |
11 | // Default domains for development
12 | return ["localhost"];
13 | }
14 |
15 | export const ALLOWED_DOMAINS = getAllowedDomains();
16 |
17 | export function isDomainAllowed(domain: string): boolean {
18 | // Check exact match first
19 | if (ALLOWED_DOMAINS.includes(domain)) {
20 | return true;
21 | }
22 |
23 | // Check if domain starts with 'www.' and the non-www version is allowed
24 | if (domain.startsWith("www.")) {
25 | const nonWwwDomain = domain.substring(4);
26 | return ALLOWED_DOMAINS.includes(nonWwwDomain);
27 | }
28 |
29 | return false;
30 | }
31 |
32 | // JavaScript version for the widget (same logic, no TypeScript)
33 | export function getDomainCheckJS(): string {
34 | return `
35 | function isDomainAllowed(domain) {
36 | const ALLOWED_DOMAINS = [${ALLOWED_DOMAINS.map((d) => `'${d}'`).join(", ")}];
37 |
38 | // Check exact match first
39 | if (ALLOWED_DOMAINS.includes(domain)) {
40 | return true;
41 | }
42 |
43 | // Check if domain starts with 'www.' and the non-www version is allowed
44 | if (domain.startsWith('www.')) {
45 | const nonWwwDomain = domain.substring(4);
46 | return ALLOWED_DOMAINS.includes(nonWwwDomain);
47 | }
48 |
49 | return false;
50 | }`;
51 | }
52 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to here/now
2 |
3 | Thanks for your interest in contributing to here/now! This document provides guidelines for contributing to the project.
4 |
5 | ## Getting Started
6 |
7 | 1. Fork the repository
8 | 2. Clone your fork locally
9 | 3. Install dependencies: `npm install`
10 | 4. Set up your environment variables (see `.env.example`)
11 | 5. Start the development server: `npm run dev`
12 |
13 | ## Development Setup
14 |
15 | ### Prerequisites
16 | - Node.js 18+
17 | - A Supabase database (or compatible PostgreSQL database)
18 |
19 | ### Environment Setup
20 | 1. Copy `.env.example` to `.env`
21 | 2. Fill in your database connection details
22 | 3. Set `ALLOWED_DOMAINS` to include your development domains
23 |
24 | ### Database Setup
25 | ```bash
26 | npm run db:push # Push schema to database
27 | npm run db:generate # Generate Prisma client
28 | ```
29 |
30 | ## Code Style
31 |
32 | - We use ESLint for code linting and Prettier for formatting
33 | - Run `npm run fix` to auto-format and fix linting issues
34 | - Run `npm run lint` to check for any remaining issues
35 | - Follow existing code patterns and conventions
36 |
37 | ## Making Changes
38 |
39 | 1. Create a new branch from `main`:
40 | ```bash
41 | git checkout -b feature/your-feature-name
42 | ```
43 |
44 | 2. Make your changes following these guidelines:
45 | - Write clear, descriptive commit messages
46 | - Keep changes focused and atomic
47 | - Add tests if applicable
48 | - Update documentation if needed
49 |
50 | 3. Test your changes:
51 | ```bash
52 | npm run fix # Format code and fix linting issues
53 | npm run build # Ensure the project builds
54 | ```
55 |
56 | 4. Push to your fork and create a pull request
57 |
58 | ## Pull Request Guidelines
59 |
60 | - Provide a clear description of what your PR does
61 | - Reference any related issues
62 | - Include screenshots for UI changes
63 | - Make sure all checks pass
64 | - Keep PRs focused on a single feature/fix
65 |
66 | ## Reporting Issues
67 |
68 | When reporting bugs, please include:
69 | - Steps to reproduce
70 | - Expected vs actual behavior
71 | - Environment details (OS, Node version, etc.)
72 | - Relevant logs or error messages
73 |
74 | ## Feature Requests
75 |
76 | - Check existing issues before creating new ones
77 | - Clearly describe the use case and benefit
78 | - Consider if the feature aligns with the project's goals
79 |
80 | ## Code of Conduct
81 |
82 | - Be respectful and inclusive
83 | - Focus on constructive feedback
84 | - Help maintain a welcoming environment for all contributors
85 |
86 | ## Questions?
87 |
88 | Feel free to open an issue for questions about contributing or join discussions in existing issues.
89 |
--------------------------------------------------------------------------------
/src/controllers/statsController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import { prisma } from "../lib/prisma.js";
3 | import { isDomainAllowed, ACTIVITY_THRESHOLD_MS } from "../lib/constants.js";
4 | import { StatsResult, QueryResult } from "../types/index.js";
5 |
6 | // Simple in-memory cache to reduce database load
7 | const statsCache = new Map();
8 | const CACHE_TTL = 30 * 1000; // 30 seconds cache
9 |
10 | export const statsController = async (req: Request, res: Response) => {
11 | const domain = req.query.domain as string;
12 | const path = req.query.path as string;
13 |
14 | try {
15 | // Validate required parameters
16 | if (!domain) {
17 | return res.status(400).json({
18 | error: "Missing required parameter: domain",
19 | });
20 | }
21 |
22 | if (!path) {
23 | return res.status(400).json({
24 | error: "Missing required parameter: path",
25 | });
26 | }
27 |
28 | // Check domain whitelist
29 | if (!isDomainAllowed(domain)) {
30 | return res.status(403).json({
31 | error: "Domain not allowed",
32 | });
33 | }
34 |
35 | // Check cache first to reduce database load
36 | const cacheKey = `${domain}:${path}`;
37 | const cached = statsCache.get(cacheKey);
38 | const now = Date.now();
39 |
40 | if (cached && now - cached.timestamp < CACHE_TTL) {
41 | return res.json(cached.data);
42 | }
43 |
44 | // Use single raw SQL query for maximum performance with large datasets
45 | const activityThresholdAgo = new Date(Date.now() - ACTIVITY_THRESHOLD_MS);
46 |
47 | // Get both counts in a single query to reduce database load and connection usage
48 | const queryResult = await prisma.$queryRaw`
49 | SELECT
50 | COUNT(DISTINCT user_id) as here_count,
51 | COUNT(DISTINCT CASE WHEN timestamp >= ${activityThresholdAgo} THEN user_id END) as now_count
52 | FROM page_events
53 | WHERE domain = ${domain} AND path = ${path}
54 | `;
55 |
56 | const queryData = (queryResult as QueryResult[])[0];
57 | const here = Number(queryData?.here_count || 0);
58 | const nowCount = Number(queryData?.now_count || 0);
59 |
60 | const result = {
61 | here,
62 | now: nowCount,
63 | domain,
64 | path,
65 | };
66 |
67 | // Cache the result to reduce database load
68 | statsCache.set(cacheKey, {
69 | data: result,
70 | timestamp: Date.now(),
71 | });
72 |
73 | // Clean up old cache entries periodically
74 | if (statsCache.size > 100) {
75 | const cutoff = Date.now() - CACHE_TTL * 2;
76 | for (const [key, value] of statsCache.entries()) {
77 | if (value.timestamp < cutoff) {
78 | statsCache.delete(key);
79 | }
80 | }
81 | }
82 |
83 | res.json(result);
84 | } catch (error) {
85 | console.error("Stats error details:", error);
86 | console.error("Error stack:", error instanceof Error ? error.stack : error);
87 | console.error("Domain:", domain, "Path:", path);
88 | res.status(500).json({
89 | error: "Failed to get stats",
90 | details: error instanceof Error ? error.message : String(error),
91 | domain,
92 | path,
93 | });
94 | }
95 | };
96 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
here/now — modern minimal webpage hit counter
2 |
3 | A minimal, self-hosted visitor tracking API that shows both **total visitor count** and **real-time visitor counts** per webpage.
4 |
5 | Hosted original and example available at [herenow.fyi](https://www.herenow.fyi).
6 |
7 | ## ✨ Features
8 |
9 | - **Real-time visitor tracking** - See current active visitors on a page
10 | - **Total visitor counts** - Track all-time unique visitors on a page
11 | - **Self-hosted** - Full control over your data
12 | - **Lightweight widget** - Single script tag integration
13 | - **Dark/light theme detection** - Automatic theme matching
14 | - **SPA support** - Works with React, Vue, Next.js, etc.
15 | - **CORS enabled** - Works from any website (domain filtering via allowlist)
16 |
17 | Keeping a link to [herenow.fyi](https://herenow.fyi) in your implementation is appreciated but not required, as this helps others discover how to implement here/now.
18 |
19 | ## 🚀 Quick Start
20 |
21 | ### 1. Clone and Install
22 |
23 | ```bash
24 | git clone https://github.com/fredrivett/here-now.git
25 | cd here-now
26 | npm install
27 | ```
28 |
29 | ### 2. Set up Database
30 |
31 | _You can use any database setup you choose, this guide works with Supabase (postgres)._
32 |
33 | Copy the environment variables:
34 |
35 | ```bash
36 | cp .env.example .env
37 | ```
38 |
39 | Set up your database:
40 |
41 | 1. Create a free PostgreSQL database at [supabase.com](https://supabase.com)
42 | 2. Go to Connect → Connection String and copy both connection strings
43 | 3. Update `.env` with your Supabase URLs:
44 | ```bash
45 | DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:6543/postgres?pgbouncer=true" # Transaction Pooler
46 | DIRECT_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:5432/postgres" # Direct connection
47 | ```
48 |
49 | Initialize database:
50 |
51 | ```bash
52 | npm run db:generate # Generates types
53 | npm run db:push # Creates tables in your database
54 | ```
55 |
56 | ### 3. Configure Domains
57 |
58 | Add your allowed domains to `.env`:
59 |
60 | ```bash
61 | ALLOWED_DOMAINS="localhost,yourdomain.com,yourotherdomain.com"
62 | ```
63 |
64 | ### 4. Start the Server
65 |
66 | ```bash
67 | npm run dev
68 | ```
69 |
70 | Your API will be available at `http://localhost:3210`
71 |
72 | ### 5. Add to Your Website
73 |
74 | Add this single line to any webpage where you wish the widget to display:
75 |
76 | ```html
77 |
78 | ```
79 |
80 | Then include the script before the closing `