Initialize authenticated webapp

This commit is contained in:
Matiss
2026-05-04 22:39:04 +01:00
parent 4a6177b891
commit d3ae3516c5
27 changed files with 2376 additions and 102 deletions

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# clerk configuration (can include secrets)
/.clerk/

View File

@@ -1,36 +1,32 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Kairas Webapp
## Getting Started
Next.js App Router app with Clerk authentication, Convex client wiring, and shadcn-style UI components.
First, run the development server:
## Setup
Copy `.env.example` to `.env.local` and fill in the Clerk keys:
```bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
```
Create or link a Convex deployment:
```bash
npx convex dev
```
That command will set `NEXT_PUBLIC_CONVEX_URL`. Configure `CLERK_JWT_ISSUER_DOMAIN` in Convex using your Clerk Frontend API URL, then rerun `npx convex dev` so Convex syncs `convex/auth.config.ts`.
## Development
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Routes:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- `/sign-in` is public.
- `/sign-up` is public.
- `/` is protected and redirects unauthenticated users to sign in.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

45
convex/_generated/api.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

23
convex/_generated/api.js Normal file
View File

@@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

58
convex/_generated/dataModel.d.ts vendored Normal file
View File

@@ -0,0 +1,58 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { AnyDataModel } from "convex/server";
import type { GenericId } from "convex/values";
/**
* No `schema.ts` file found!
*
* This generated code has permissive types like `Doc = any` because
* Convex doesn't know your schema. If you'd like more type safety, see
* https://docs.convex.dev/using/schemas for instructions on how to add a
* schema file.
*
* After you change a schema, rerun codegen with `npx convex dev`.
*/
/**
* The names of all of your Convex tables.
*/
export type TableNames = string;
/**
* The type of a document stored in Convex.
*/
export type Doc = any;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*/
export type Id<TableName extends TableNames = TableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = AnyDataModel;

143
convex/_generated/server.d.ts vendored Normal file
View File

@@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

10
convex/auth.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
applicationID: "convex",
},
],
} satisfies AuthConfig;

View File

@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"convex/_generated/**",
]),
]);

874
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "kairas-webapp-scaffold",
"name": "kairas-webapp",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -9,9 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@clerk/nextjs": "^7.3.0",
"@clerk/themes": "^2.4.57",
"@radix-ui/react-slot": "^1.2.4",
"@vis.gl/react-google-maps": "^1.8.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.37.0",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -3,11 +3,49 @@
:root {
--background: #ffffff;
--foreground: #171717;
--card: #ffffff;
--card-foreground: #171717;
--popover: #ffffff;
--popover-foreground: #171717;
--primary: #18181b;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #18181b;
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@@ -16,11 +54,35 @@
:root {
--background: #0a0a0a;
--foreground: #ededed;
--card: #18181b;
--card-foreground: #fafafa;
--popover: #18181b;
--popover-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #18181b;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #d4d4d8;
}
}
* {
border-color: var(--border);
}
html {
height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,5 +1,14 @@
import type { Metadata } from "next";
import {
ClerkProvider,
Show,
SignInButton,
SignUpButton,
} from "@clerk/nextjs";
import { dark } from "@clerk/themes";
import { Geist, Geist_Mono } from "next/font/google";
import { AppSidebar } from "@/components/app-sidebar";
import { ConvexClientProvider } from "@/components/convex-client-provider";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +22,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Kairas App",
description: "Authenticated Kairas workspace.",
};
export default function RootLayout({
@@ -27,7 +36,40 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="flex min-h-full flex-col">
<ClerkProvider appearance={{ baseTheme: dark }}>
<ConvexClientProvider>
<Show when="signed-out">
<header className="border-b bg-card">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
<div>
<p className="text-sm font-medium text-muted-foreground">
Kairas
</p>
<p className="text-lg font-semibold tracking-normal">
Maps Studio
</p>
</div>
<nav className="flex items-center gap-3">
<SignInButton mode="modal">
<button className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
Sign in
</button>
</SignInButton>
<SignUpButton mode="modal">
<button className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
Sign up
</button>
</SignUpButton>
</nav>
</div>
</header>
</Show>
<AppSidebar />
<div className="min-h-screen md:pl-64">{children}</div>
</ConvexClientProvider>
</ClerkProvider>
</body>
</html>
);
}

9
src/app/maps/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { auth } from "@clerk/nextjs/server";
import { BusinessMap } from "@/components/business-map";
export default async function MapsPage() {
await auth.protect();
return <BusinessMap />;
}

110
src/app/overview/page.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { auth } from "@clerk/nextjs/server";
import { ArrowRight, Building2, LineChart, MapPinned } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const stats = [
["42", "Mapped leads"],
["18", "Priority prospects"],
["7", "Markets tracked"],
];
const cards = [
{
title: "Research local markets",
description:
"Drop into any area and pull businesses, ratings, websites, and review context from Google Maps.",
icon: MapPinned,
},
{
title: "Qualify business profiles",
description:
"Use website availability, rating count, and recent customer language to prioritize outreach.",
icon: Building2,
},
{
title: "Track opportunities",
description:
"This sample dashboard is ready to connect to Convex collections when you add saved places.",
icon: LineChart,
},
];
export default async function OverviewPage() {
await auth.protect();
return (
<main className="min-h-screen bg-background px-4 py-8 text-foreground sm:px-6 lg:px-8">
<section className="mx-auto max-w-6xl">
<div className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<div className="rounded-lg border bg-card p-8">
<p className="text-sm font-medium text-muted-foreground">
Kairas Maps Studio
</p>
<h1 className="mt-4 max-w-3xl text-4xl font-semibold tracking-normal sm:text-5xl">
A sample workspace for local business research.
</h1>
<p className="mt-5 max-w-2xl text-base leading-7 text-muted-foreground">
This overview page is a basic sample site: metrics, workflow
cards, and a clear entry point into the full-screen maps tool.
</p>
<div className="mt-8">
<Button asChild>
<Link href="/maps">
Open maps
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Pipeline Snapshot</CardTitle>
<CardDescription>
Placeholder metrics for the first dashboard pass.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{stats.map(([value, label]) => (
<div
key={label}
className="flex items-center justify-between rounded-md border bg-muted/40 p-4"
>
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-2xl font-semibold">{value}</span>
</div>
))}
</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.title}>
<CardHeader>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Icon className="h-5 w-5" />
</div>
<CardTitle className="text-lg">{card.title}</CardTitle>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</section>
</main>
);
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/overview");
}

View File

@@ -0,0 +1,14 @@
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<main className="grid min-h-screen place-items-center bg-muted px-4 py-10">
<SignIn
path="/sign-in"
routing="path"
signUpUrl="/sign-up"
fallbackRedirectUrl="/"
/>
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<main className="grid min-h-screen place-items-center bg-muted px-4 py-10">
<SignUp
path="/sign-up"
routing="path"
signInUrl="/sign-in"
fallbackRedirectUrl="/"
/>
</main>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { Show, UserButton } from "@clerk/nextjs";
import { BarChart3, Map, Menu, Search, Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{
href: "/overview",
label: "Overview",
icon: BarChart3,
},
{
href: "/maps",
label: "Maps",
icon: Map,
},
];
export function AppSidebar() {
const pathname = usePathname();
return (
<Show when="signed-in">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-card md:flex">
<div className="flex h-16 items-center gap-3 border-b px-5">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Search className="h-4 w-4" />
</div>
<div>
<p className="text-sm text-muted-foreground">Kairas</p>
<p className="font-semibold tracking-normal">Maps Studio</p>
</div>
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="border-t p-3">
<div className="mb-3 rounded-md border bg-muted/40 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Settings className="h-4 w-4" />
Workspace
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Authenticated map research and local business discovery.
</p>
</div>
<UserButton showName />
</div>
</aside>
<div className="sticky top-0 z-30 flex h-14 items-center justify-between border-b bg-card px-4 md:hidden">
<div className="flex items-center gap-2">
<Menu className="h-5 w-5" />
<span className="font-semibold">Kairas</span>
</div>
<UserButton />
</div>
</Show>
);
}

View File

@@ -0,0 +1,390 @@
"use client";
import {
APIProvider,
Map,
Marker,
useMap,
useMapsLibrary,
} from "@vis.gl/react-google-maps";
import {
ExternalLink,
Globe2,
LocateFixed,
MapPin,
MousePointer2,
Search,
Star,
} from "lucide-react";
import { FormEvent, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type BusinessReview = {
author: string;
rating: number | null;
text: string | null;
when: string | null;
url: string | null;
};
type BusinessResult = {
id: string;
name: string;
address: string | undefined;
rating: number | undefined;
ratingCount: number | undefined;
website: string | undefined;
googleMapsUrl: string | undefined;
reviews: BusinessReview[];
position: google.maps.LatLngLiteral;
};
const defaultCenter = { lat: 53.3498, lng: -6.2603 };
const fallbackRadius = 1500;
export function BusinessMap() {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
const mapId = process.env.NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID;
if (!apiKey) {
return (
<div className="grid h-full min-h-[calc(100vh-3.5rem)] place-items-center bg-background p-6 text-center md:min-h-screen">
<div className="max-w-md space-y-3 rounded-lg border bg-card p-6">
<MapPin className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="text-xl font-semibold">Google Maps is not configured</h1>
<p className="text-sm leading-6 text-muted-foreground">
Add `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` to `.env.local`, enable Maps
JavaScript API and Places API, then restart the dev server.
</p>
</div>
</div>
);
}
return (
<APIProvider apiKey={apiKey} libraries={["places"]}>
<BusinessMapContent mapId={mapId} />
</APIProvider>
);
}
function BusinessMapContent({ mapId }: { mapId?: string }) {
const map = useMap();
const places = useMapsLibrary("places");
const [pointer, setPointer] =
useState<google.maps.LatLngLiteral>(defaultCenter);
const [query, setQuery] = useState("restaurants");
const [radius, setRadius] = useState(fallbackRadius);
const [results, setResults] = useState<BusinessResult[]>([]);
const [selected, setSelected] = useState<BusinessResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const center = useMemo(
() => selected?.position ?? pointer,
[pointer, selected],
);
function placePointer(position: google.maps.LatLngLiteral) {
setPointer(position);
setSelected(null);
map?.panTo(position);
}
async function findBusinesses(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
if (!places || !map || query.trim().length === 0) {
return;
}
setIsLoading(true);
setError(null);
try {
const { Place } = places;
const { places: foundPlaces } = await Place.searchByText({
textQuery: `${query} near ${pointer.lat}, ${pointer.lng}`,
fields: [
"id",
"displayName",
"formattedAddress",
"googleMapsURI",
"location",
"rating",
"reviews",
"userRatingCount",
"websiteURI",
],
maxResultCount: 12,
locationBias: {
center: pointer,
radius,
},
});
const nextResults = foundPlaces
.map((place) => {
const location = place.location;
if (!place.id || !place.displayName || !location) {
return null;
}
return {
id: place.id,
name: place.displayName,
address: place.formattedAddress ?? undefined,
rating: place.rating ?? undefined,
ratingCount: place.userRatingCount ?? undefined,
website: place.websiteURI ?? undefined,
googleMapsUrl: place.googleMapsURI ?? undefined,
reviews: (place.reviews ?? []).slice(0, 3).map((review) => ({
author: review.authorAttribution?.displayName ?? "Google user",
rating: review.rating,
text: review.text,
when: review.relativePublishTimeDescription,
url: review.googleMapsURI,
})),
position: location.toJSON(),
} satisfies BusinessResult;
})
.filter((place): place is BusinessResult => place !== null);
setResults(nextResults);
setSelected(nextResults[0] ?? null);
if (nextResults[0]) {
map.panTo(nextResults[0].position);
map.setZoom(14);
}
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Google Places search failed.",
);
} finally {
setIsLoading(false);
}
}
return (
<div className="grid h-[calc(100vh-3.5rem)] min-h-[720px] bg-background md:h-screen md:grid-cols-[1fr_420px]">
<section className="relative min-h-[420px]">
<Map
defaultCenter={defaultCenter}
defaultZoom={13}
center={center}
mapId={mapId}
gestureHandling="greedy"
disableDefaultUI={false}
className="h-full w-full"
onClick={(event) => {
if (event.detail.latLng) {
placePointer(event.detail.latLng);
}
}}
>
<Marker position={pointer} title="Search point" />
{results.map((business) => (
<Marker
key={business.id}
position={business.position}
title={business.name}
onClick={() => setSelected(business)}
/>
))}
</Map>
<div className="pointer-events-none absolute left-4 top-4 max-w-sm rounded-lg border bg-card/95 p-4 shadow-sm backdrop-blur">
<div className="flex items-center gap-2 text-sm font-medium">
<MousePointer2 className="h-4 w-4" />
Drop a pointer
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Click anywhere on the map, then search for local businesses around
that point.
</p>
</div>
</section>
<aside className="flex min-h-0 flex-col border-t bg-card md:border-l md:border-t-0">
<form onSubmit={findBusinesses} className="space-y-4 border-b p-4">
<div>
<h1 className="text-xl font-semibold tracking-normal">
Local business finder
</h1>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Pull businesses, websites, Google links, ratings, and available
review snippets from Google Places.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="business-query">
Business type
</label>
<div className="flex gap-2">
<input
id="business-query"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-10 min-w-0 flex-1 rounded-md border bg-background px-3 text-sm outline-none transition focus:ring-2 focus:ring-ring"
placeholder="cafes, gyms, web design agencies"
/>
<Button type="submit" disabled={isLoading || !places}>
<Search className="h-4 w-4" />
{isLoading ? "Finding" : "Find"}
</Button>
</div>
</div>
<div className="grid grid-cols-[1fr_96px] gap-3">
<label className="space-y-2 text-sm font-medium">
Radius
<input
value={radius}
min={500}
max={5000}
step={250}
type="range"
onChange={(event) => setRadius(Number(event.target.value))}
className="block w-full accent-foreground"
/>
</label>
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{(radius / 1000).toFixed(1)} km
</div>
</div>
<div className="rounded-md border bg-muted/40 p-3 text-xs leading-5 text-muted-foreground">
<LocateFixed className="mb-2 h-4 w-4" />
{pointer.lat.toFixed(5)}, {pointer.lng.toFixed(5)}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</form>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-3">
{results.length === 0 ? (
<p className="rounded-md border bg-background p-4 text-sm leading-6 text-muted-foreground">
No businesses loaded yet. Place the pointer and run a search.
</p>
) : null}
{results.map((business) => (
<BusinessCard
key={business.id}
business={business}
isSelected={selected?.id === business.id}
onSelect={() => setSelected(business)}
/>
))}
</div>
</div>
</aside>
</div>
);
}
function BusinessCard({
business,
isSelected,
onSelect,
}: {
business: BusinessResult;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"block w-full rounded-lg border bg-background p-4 text-left transition",
isSelected ? "border-primary ring-2 ring-ring" : "hover:bg-accent",
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{business.name}</p>
{business.address ? (
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{business.address}
</p>
) : null}
</div>
{business.rating ? (
<span className="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground">
<Star className="h-3 w-3 fill-current" />
{business.rating.toFixed(1)}
</span>
) : null}
</div>
{business.ratingCount ? (
<p className="mt-2 text-xs text-muted-foreground">
{business.ratingCount.toLocaleString()} Google reviews
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{business.website ? (
<a
href={business.website}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium hover:bg-accent"
>
<Globe2 className="h-3 w-3" />
Website
</a>
) : null}
{business.googleMapsUrl ? (
<a
href={business.googleMapsUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium hover:bg-accent"
>
<ExternalLink className="h-3 w-3" />
Google Maps
</a>
) : null}
</div>
{business.reviews.length > 0 ? (
<div className="mt-4 space-y-2">
{business.reviews.map((review, index) => (
<div key={`${business.id}-${index}`} className="rounded-md bg-muted/50 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium">{review.author}</p>
{review.rating ? (
<p className="text-xs text-muted-foreground">
{review.rating.toFixed(1)}
</p>
) : null}
</div>
{review.text ? (
<p className="mt-1 line-clamp-3 text-xs leading-5 text-muted-foreground">
{review.text}
</p>
) : null}
{review.when ? (
<p className="mt-1 text-[11px] text-muted-foreground">
{review.when}
</p>
) : null}
</div>
))}
</div>
) : null}
</button>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { useAuth } from "@clerk/nextjs";
import { ConvexReactClient } from "convex/react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import type { ReactNode } from "react";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const convex = convexUrl ? new ConvexReactClient(convexUrl) : null;
export function ConvexClientProvider({ children }: { children: ReactNode }) {
if (!convex) {
return children;
}
return (
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import {
APIProvider,
Map,
Marker,
useMap,
useMapsLibrary,
} from "@vis.gl/react-google-maps";
import { MapPin, Search } from "lucide-react";
import { FormEvent, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
type PlaceResult = {
id: string;
name: string;
address: string | undefined;
rating: number | undefined;
position: google.maps.LatLngLiteral;
};
const defaultCenter = { lat: 53.3498, lng: -6.2603 };
export function GooglePlacesMap() {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
const mapId = process.env.NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID;
if (!apiKey) {
return (
<div className="grid min-h-[560px] place-items-center rounded-lg border bg-card p-6 text-center">
<div className="max-w-md space-y-3">
<MapPin className="mx-auto h-10 w-10 text-muted-foreground" />
<h3 className="text-xl font-semibold">Google Maps is not configured</h3>
<p className="text-sm leading-6 text-muted-foreground">
Add `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` to `.env.local`, enable Maps
JavaScript API and Places API, then restart the dev server.
</p>
</div>
</div>
);
}
return (
<APIProvider apiKey={apiKey} libraries={["places"]}>
<PlacesMapContent mapId={mapId} />
</APIProvider>
);
}
function PlacesMapContent({ mapId }: { mapId?: string }) {
const map = useMap();
const places = useMapsLibrary("places");
const [query, setQuery] = useState("coffee near Dublin");
const [results, setResults] = useState<PlaceResult[]>([]);
const [selected, setSelected] = useState<PlaceResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const center = useMemo(
() => selected?.position ?? results[0]?.position ?? defaultCenter,
[results, selected],
);
async function searchPlaces(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!places || !map || query.trim().length === 0) {
return;
}
setIsLoading(true);
setError(null);
try {
const { Place } = places;
const { places: foundPlaces } = await Place.searchByText({
textQuery: query,
fields: ["id", "displayName", "formattedAddress", "location", "rating"],
maxResultCount: 6,
locationBias: map.getBounds() ?? undefined,
});
const nextResults = foundPlaces
.map((place) => {
const location = place.location;
if (!place.id || !place.displayName || !location) {
return null;
}
return {
id: place.id,
name: place.displayName,
address: place.formattedAddress ?? undefined,
rating: place.rating ?? undefined,
position: location.toJSON(),
} satisfies PlaceResult;
})
.filter((place): place is PlaceResult => place !== null);
setResults(nextResults);
setSelected(nextResults[0] ?? null);
if (nextResults.length > 0) {
map.panTo(nextResults[0].position);
map.setZoom(13);
}
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Google Places search failed.",
);
} finally {
setIsLoading(false);
}
}
return (
<div className="grid min-h-[620px] overflow-hidden rounded-lg border bg-card lg:grid-cols-[1fr_360px]">
<div className="relative min-h-[420px]">
<Map
defaultCenter={defaultCenter}
defaultZoom={12}
center={center}
mapId={mapId}
gestureHandling="greedy"
disableDefaultUI={false}
className="h-full min-h-[420px] w-full"
>
{results.map((place) => (
<Marker
key={place.id}
position={place.position}
title={place.name}
onClick={() => setSelected(place)}
/>
))}
</Map>
</div>
<aside className="flex flex-col border-t bg-background lg:border-l lg:border-t-0">
<form onSubmit={searchPlaces} className="border-b p-4">
<label className="text-sm font-medium" htmlFor="places-query">
Pull data from Google Maps
</label>
<div className="mt-3 flex gap-2">
<input
id="places-query"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-10 min-w-0 flex-1 rounded-md border bg-background px-3 text-sm outline-none transition focus:ring-2 focus:ring-ring"
placeholder="Restaurants near Dublin"
/>
<Button type="submit" disabled={isLoading || !places}>
<Search className="h-4 w-4" />
{isLoading ? "Searching" : "Search"}
</Button>
</div>
{error ? (
<p className="mt-3 text-sm text-destructive">{error}</p>
) : null}
</form>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-3">
{results.length === 0 ? (
<p className="rounded-md border bg-muted/40 p-4 text-sm leading-6 text-muted-foreground">
Search for a business, address, or place type. Results from
Google Places will appear here and on the map.
</p>
) : null}
{results.map((place) => (
<button
key={place.id}
type="button"
onClick={() => setSelected(place)}
className="w-full rounded-md border bg-card p-4 text-left transition hover:bg-accent"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{place.name}</p>
{place.address ? (
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{place.address}
</p>
) : null}
</div>
{place.rating ? (
<span className="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground">
{place.rating.toFixed(1)}
</span>
) : null}
</div>
</button>
))}
</div>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
className={cn("text-2xl font-semibold leading-none tracking-normal", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent };

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

16
src/proxy.ts Normal file
View File

@@ -0,0 +1,16 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};