Initialize authenticated webapp
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -39,3 +39,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
|
||||
52
README.md
52
README.md
@@ -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
21
components.json
Normal 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
45
convex/_generated/api.d.ts
vendored
Normal 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
23
convex/_generated/api.js
Normal 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
58
convex/_generated/dataModel.d.ts
vendored
Normal 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
143
convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
93
convex/_generated/server.js
Normal file
93
convex/_generated/server.js
Normal 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
10
convex/auth.config.ts
Normal 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;
|
||||
@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"convex/_generated/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
874
package-lock.json
generated
874
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
9
src/app/maps/page.tsx
Normal 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
110
src/app/overview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
14
src/app/sign-in/[[...sign-in]]/page.tsx
Normal file
14
src/app/sign-in/[[...sign-in]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/sign-up/[[...sign-up]]/page.tsx
Normal file
14
src/app/sign-up/[[...sign-up]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/app-sidebar.tsx
Normal file
86
src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
390
src/components/business-map.tsx
Normal file
390
src/components/business-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/convex-client-provider.tsx
Normal file
21
src/components/convex-client-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/components/google-places-map.tsx
Normal file
204
src/components/google-places-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/button.tsx
Normal file
54
src/components/ui/button.tsx
Normal 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 };
|
||||
42
src/components/ui/card.tsx
Normal file
42
src/components/ui/card.tsx
Normal 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
6
src/lib/utils.ts
Normal 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
16
src/proxy.ts
Normal 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)(.*)",
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user