Initialize authenticated webapp
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
```bash
|
||||||
npm run dev
|
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.
|
- `/sign-in` is public.
|
||||||
|
- `/sign-up` is public.
|
||||||
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.
|
- `/` is protected and redirects unauthenticated users to sign in.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
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/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,9 +9,18 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -3,11 +3,49 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--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-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
@@ -16,11 +54,35 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--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 {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
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 { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { ConvexClientProvider } from "@/components/convex-client-provider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -13,8 +22,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Kairas App",
|
||||||
description: "Generated by create next app",
|
description: "Authenticated Kairas workspace.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -27,7 +36,40 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
redirect("/overview");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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