Setup project dependencies and initial structure for Canadian Alternatives app

- Added core dependencies: Shadcn UI, Tailwind, Framer Motion, React Hook Form, Zod
- Configured global CSS with dark mode and custom color palette
- Updated README with project description, tech stack, and setup instructions
- Restructured home page with product search and value proposition sections
- Prepared layout with navigation bar and footer placeholders
- Integrated nuqs for URL state management
This commit is contained in:
boilerrat 2025-03-04 22:07:10 -05:00
parent 9d4065dceb
commit 1b73482ce8
42 changed files with 4338 additions and 138 deletions

133
.cursor/rules/nextsupa.mdc Normal file
View File

@ -0,0 +1,133 @@
---
description: expert developer in TypeScript, Node.js, Next.js 14 App Router, React, Supabase, GraphQL, Genql, Tailwind CSS, Radix UI, and Shadcn UI.
globs: *.tsx, *.ts, src/**/*.tsx, src/**/*.ts, src/app/**/*.tsx, src/components/**/*.tsx
alwaysApply: false
---
You are an expert developer in TypeScript, Node.js, Next.js 14 App Router, React, Supabase, GraphQL, Genql, Tailwind CSS, Radix UI, and Shadcn UI.
Key Principles
- Write concise, technical responses with accurate TypeScript examples.
- Use functional, declarative programming. Avoid classes.
- Prefer iteration and modularization over duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
- Favor named exports for components.
- Use the Receive an Object, Return an Object (RORO) pattern.
JavaScript/TypeScript
- Use "function" keyword for pure functions. Omit semicolons.
- Use TypeScript for all code. Prefer interfaces over types.
- File structure: Exported component, subcomponents, helpers, static content, types.
- Avoid unnecessary curly braces in conditional statements.
- For single-line statements in conditionals, omit curly braces.
- Use concise, one-line syntax for simple conditional statements (e.g., if (condition) doSomething()).
Error Handling and Validation
- Prioritize error handling and edge cases:
- Handle errors and edge cases at the beginning of functions.
- Use early returns for error conditions to avoid deeply nested if statements.
- Place the happy path last in the function for improved readability.
- Avoid unnecessary else statements; use if-return pattern instead.
- Use guard clauses to handle preconditions and invalid states early.
- Implement proper error logging and user-friendly error messages.
- Consider using custom error types or error factories for consistent error handling.
AI SDK
- Use the Vercel AI SDK UI for implementing streaming chat UI.
- Use the Vercel AI SDK Core to interact with language models.
- Use the Vercel AI SDK RSC and Stream Helpers to stream and help with the generations.
- Implement proper error handling for AI responses and model switching.
- Implement fallback mechanisms for when an AI model is unavailable.
- Handle rate limiting and quota exceeded scenarios gracefully.
- Provide clear error messages to users when AI interactions fail.
- Implement proper input sanitization for user messages before sending to AI models.
- Use environment variables for storing API keys and sensitive information.
React/Next.js
- Use functional components and TypeScript interfaces.
- Use declarative JSX.
- Use function, not const, for components.
- Use Shadcn UI, Radix, and Tailwind CSS for components and styling.
- Implement responsive design with Tailwind CSS.
- Use mobile-first approach for responsive design.
- Place static content and interfaces at file end.
- Use content variables for static content outside render functions.
- Minimize 'use client', 'useEffect', and 'setState'. Favor React Server Components (RSC).
- Use Zod for form validation.
- Wrap client components in Suspense with fallback.
- Use dynamic loading for non-critical components.
- Optimize images: WebP format, size data, lazy loading.
- Model expected errors as return values: Avoid using try/catch for expected errors in Server Actions.
- Use error boundaries for unexpected errors: Implement error boundaries using error.tsx and global-error.tsx files.
- Use useActionState with react-hook-form for form validation.
- Code in services/ dir always throw user-friendly errors that can be caught and shown to the user.
- Use next-safe-action for all server actions.
- Implement type-safe server actions with proper validation.
- Handle errors gracefully and return appropriate responses.
Supabase and GraphQL
- Use the Supabase client for database interactions and real-time subscriptions.
- Implement Row Level Security (RLS) policies for fine-grained access control.
- Use Supabase Auth for user authentication and management.
- Leverage Supabase Storage for file uploads and management.
- Use Supabase Edge Functions for serverless API endpoints when needed.
- Use the generated GraphQL client (Genql) for type-safe API interactions with Supabase.
- Optimize GraphQL queries to fetch only necessary data.
- Use Genql queries for fetching large datasets efficiently.
- Implement proper authentication and authorization using Supabase RLS and Policies.
Key Conventions
1. Rely on Next.js App Router for state changes and routing.
2. Prioritize Web Vitals (LCP, CLS, FID).
3. Minimize 'use client' usage:
- Prefer server components and Next.js SSR features.
- Use 'use client' only for Web API access in small components.
- Avoid using 'use client' for data fetching or state management.
4. Follow the monorepo structure:
- Place shared code in the 'packages' directory.
- Keep app-specific code in the 'apps' directory.
5. Use Taskfile commands for development and deployment tasks.
6. Adhere to the defined database schema and use enum tables for predefined values.
Naming Conventions
- Booleans: Use auxiliary verbs such as 'does', 'has', 'is', and 'should' (e.g., isDisabled, hasError).
- Filenames: Use lowercase with dash separators (e.g., auth-wizard.tsx).
- File extensions: Use .config.ts, .test.ts, .context.tsx, .type.ts, .hook.ts as appropriate.
Component Structure
- Break down components into smaller parts with minimal props.
- Suggest micro folder structure for components.
- Use composition to build complex components.
- Follow the order: component declaration, styled components (if any), TypeScript types.
Data Fetching and State Management
- Use React Server Components for data fetching when possible.
- Implement the preload pattern to prevent waterfalls.
- Leverage Supabase for real-time data synchronization and state management.
- Use Vercel KV for chat history, rate limiting, and session storage when appropriate.
Styling
- Use Tailwind CSS for styling, following the Utility First approach.
- Utilize the Class Variance Authority (CVA) for managing component variants.
Testing
- Implement unit tests for utility functions and hooks.
- Use integration tests for complex components and pages.
- Implement end-to-end tests for critical user flows.
- Use Supabase local development for testing database interactions.
Accessibility
- Ensure interfaces are keyboard navigable.
- Implement proper ARIA labels and roles for components.
- Ensure color contrast ratios meet WCAG standards for readability.
Documentation
- Provide clear and concise comments for complex logic.
- Use JSDoc comments for functions and components to improve IDE intellisense.
- Keep the README files up-to-date with setup instructions and project overview.
- Document Supabase schema, RLS policies, and Edge Functions when used.
Refer to Next.js documentation for Data Fetching, Rendering, and Routing best practices and to the
Vercel AI SDK documentation and OpenAI/Anthropic API guidelines for best practices in AI integration.

127
README.md
View File

@ -1,36 +1,121 @@
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).
# Canadian Alternatives
A web application that helps users find Canadian-made alternatives to American products. This project aims to support Canadian businesses and entrepreneurs by promoting locally made products and identifying market opportunities.
## Features
- Search for American products and find their Canadian alternatives
- Browse products by category
- View detailed product information and alternatives
- Discover business opportunities for Canadian entrepreneurs
- Submit new business opportunity suggestions
## Tech Stack
- **Frontend**: Next.js 14 (App Router), React, TypeScript
- **UI Components**: Shadcn UI, Tailwind CSS, Framer Motion
- **Database**: Supabase
- **Form Handling**: React Hook Form, Zod
- **State Management**: React Server Components, URL state with nuqs
## Getting Started
First, run the development server:
### Prerequisites
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- Node.js 18.17 or later
- npm or yarn
- Supabase account (for database)
### Environment Setup
Create a `.env.local` file in the root directory with the following variables:
```
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Installation
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
1. Clone the repository:
```bash
git clone https://github.com/yourusername/canadian-alternatives.git
cd canadian-alternatives
```
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.
2. Install dependencies:
```bash
npm install
# or
yarn install
```
## Learn More
3. Run the development server:
```bash
npm run dev
# or
yarn dev
```
To learn more about Next.js, take a look at the following resources:
4. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
- [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.
## Database Schema
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
The application uses the following Supabase tables:
## Deploy on Vercel
### Products
- `id`: string (UUID)
- `name`: string
- `description`: string
- `company`: string
- `country_of_origin`: string
- `category`: string
- `image_url`: string (optional)
- `created_at`: timestamp
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.
### Alternatives
- `id`: string (UUID)
- `product_id`: string (references Products.id)
- `name`: string
- `description`: string
- `company`: string
- `category`: string
- `image_url`: string (optional)
- `created_at`: timestamp
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Opportunities
- `id`: string (UUID)
- `category`: string
- `description`: string
- `created_at`: timestamp
## Deployment
This application can be deployed to any hosting platform that supports Next.js applications, such as Vercel, Netlify, or a VPS.
### Deploying to Vercel
1. Push your code to a GitHub repository
2. Import the project in Vercel
3. Configure environment variables
4. Deploy
### Deploying to a VPS
1. Build the application:
```bash
npm run build
```
2. Start the production server:
```bash
npm start
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the MIT License - see the LICENSE file for details.

21
components.json Normal file
View File

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

1548
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,19 +9,38 @@
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@supabase/supabase-js": "^2.49.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"framer-motion": "^12.4.10",
"lucide-react": "^0.477.0",
"next": "15.2.1",
"nuqs": "^2.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.2.1"
"react-hook-form": "^7.54.2",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.2.1",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}

63
src/app/about/page.tsx Normal file
View File

@ -0,0 +1,63 @@
export const metadata = {
title: 'About | Canadian Alternatives',
description: 'Learn about the Canadian Alternatives project and our mission to promote Canadian products',
};
export default function AboutPage() {
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight">
About Canadian Alternatives
</h1>
<p className="mt-3 max-w-md mx-auto text-lg text-gray-500 sm:text-xl md:mt-5 md:max-w-3xl">
Our mission is to help Canadians discover and support local alternatives to American products.
</p>
</div>
<div className="prose prose-lg mx-auto">
<h2>Our Mission</h2>
<p>
Canadian Alternatives was created to help consumers find Canadian-made products as alternatives to American imports.
By supporting Canadian businesses, we aim to strengthen our local economy, reduce our carbon footprint,
and promote high-quality products made right here in Canada.
</p>
<h2>Why Choose Canadian?</h2>
<ul>
<li>
<strong>Support Local Economy:</strong> When you buy Canadian products, you're supporting Canadian jobs,
businesses, and communities.
</li>
<li>
<strong>Environmental Impact:</strong> Local products often have a smaller carbon footprint due to reduced
transportation distances.
</li>
<li>
<strong>Quality & Standards:</strong> Canadian products are manufactured under high standards for quality,
safety, and labor conditions.
</li>
<li>
<strong>Economic Independence:</strong> Buying Canadian helps reduce dependency on foreign imports and
strengthens our economic resilience.
</li>
</ul>
<h2>For Entrepreneurs</h2>
<p>
We also maintain a list of product categories where Canadian alternatives are needed. These represent
opportunities for Canadian entrepreneurs to develop new products and businesses. If you're looking to
start a business or expand your product line, check out our <a href="/opportunities" className="text-red-600 hover:underline">Business Opportunities</a> page.
</p>
<h2>Contact Us</h2>
<p>
Have a suggestion for a Canadian alternative? Want to list your Canadian-made product?
Please <a href="/contact" className="text-red-600 hover:underline">contact us</a> and we'll be happy to help.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import { Suspense } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlternativeCard } from '@/components/alternative-card';
import { supabase } from '@/lib/supabase';
import { getAlternatives } from '@/lib/services/products';
import { type Product } from '@/lib/services/products';
interface AlternativesPageProps {
params: {
productId: string;
};
}
export default async function AlternativesPage({ params }: AlternativesPageProps) {
const { productId } = params;
// Fetch the product details
const { data: product, error } = await supabase
.from('products')
.select('*')
.eq('id', productId)
.single();
if (error || !product) {
notFound();
}
// Fetch alternatives
const alternatives = await getAlternatives(productId);
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-6">
<Link href="/search" className="text-red-600 hover:underline flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to search
</Link>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-lg mb-12">
<div className="px-4 py-5 sm:px-6 flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">{product.company}</p>
</div>
<Badge variant="outline">{product.country_of_origin}</Badge>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{product.image_url && (
<div className="relative h-64 md:h-full">
<Image
src={product.image_url}
alt={product.name}
fill
className="object-cover rounded-md"
/>
</div>
)}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Description</h3>
<p className="text-gray-600 mb-4">{product.description}</p>
<div className="flex items-center mb-4">
<span className="text-sm font-medium text-gray-500 mr-2">Category:</span>
<Badge variant="secondary">{product.category}</Badge>
</div>
</div>
</div>
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Canadian Alternatives</h2>
<Suspense fallback={<div className="text-center py-12">Loading alternatives...</div>}>
{alternatives.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{alternatives.map((alternative) => (
<AlternativeCard key={alternative.id} alternative={alternative} />
))}
</div>
) : (
<Card className="bg-gray-50">
<CardHeader>
<CardTitle>No Canadian Alternatives Found</CardTitle>
<CardDescription>
We couldn't find any Canadian alternatives for this product yet.
</CardDescription>
</CardHeader>
<CardContent>
<p className="mb-4">
This represents a potential business opportunity for Canadian entrepreneurs!
</p>
<Link href="/opportunities">
<Button variant="default">View Business Opportunities</Button>
</Link>
</CardContent>
</Card>
)}
</Suspense>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { ProductCard } from '@/components/product-card';
import { supabase } from '@/lib/supabase';
import { type Product } from '@/lib/services/products';
interface CategoryProductsProps {
category: string;
}
export async function CategoryProducts({ category }: CategoryProductsProps) {
// Fetch products in this category
const { data: products, error } = await supabase
.from('products')
.select('*')
.eq('category', category)
.order('name');
if (error || !products || products.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-700 mb-4">No products found in the {category} category.</p>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

View File

@ -0,0 +1,64 @@
import { Suspense } from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { CategoryProducts } from './category-products';
import { supabase } from '@/lib/supabase';
interface CategoryPageProps {
params: {
category: string;
};
}
export async function generateMetadata({ params }: CategoryPageProps) {
const category = decodeURIComponent(params.category);
return {
title: `${category} Products | Canadian Alternatives`,
description: `Browse American ${category} products and their Canadian alternatives`,
};
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const category = decodeURIComponent(params.category);
// Verify the category exists
const { count } = await supabase
.from('products')
.select('*', { count: 'exact', head: true })
.eq('category', category);
if (!count) {
notFound();
}
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-6">
<Link href="/categories" className="text-red-600 hover:underline flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to categories
</Link>
</div>
<div className="mb-12">
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
<Badge variant="outline" className="mr-3 text-lg py-1 px-3">{category}</Badge>
<span>Products</span>
</h1>
<p className="mt-2 text-gray-600">
Browse American {category} products and their Canadian alternatives.
</p>
</div>
<Suspense fallback={<div className="text-center py-12">Loading products...</div>}>
<CategoryProducts category={category} />
</Suspense>
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { getAllCategories } from '@/lib/services/products';
import { supabase } from '@/lib/supabase';
export async function CategoryList() {
// Get all unique categories
const categories = await getAllCategories();
if (categories.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No product categories found. Start by adding some products!</p>
</div>
);
}
// For each category, get a count of products and alternatives
const categoriesWithCounts = await Promise.all(
categories.map(async (category) => {
// Count products in this category
const { count: productCount } = await supabase
.from('products')
.select('*', { count: 'exact', head: true })
.eq('category', category);
// Count alternatives in this category
const { count: alternativeCount } = await supabase
.from('alternatives')
.select('*', { count: 'exact', head: true })
.eq('category', category);
return {
name: category,
productCount: productCount || 0,
alternativeCount: alternativeCount || 0
};
})
);
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{categoriesWithCounts.map((category) => (
<Card key={category.name} className="flex flex-col">
<CardHeader>
<CardTitle className="text-xl">{category.name}</CardTitle>
<CardDescription>
{category.productCount} American product{category.productCount !== 1 ? 's' : ''} and {category.alternativeCount} Canadian alternative{category.alternativeCount !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="flex space-x-2">
<Badge variant="outline">{category.productCount} Products</Badge>
<Badge variant="secondary">{category.alternativeCount} Alternatives</Badge>
</div>
</CardContent>
<CardFooter>
<Link href={`/categories/${encodeURIComponent(category.name)}`} className="w-full">
<Button variant="default" className="w-full">Browse {category.name}</Button>
</Link>
</CardFooter>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,28 @@
import { Suspense } from 'react';
import { CategoryList } from './category-list';
export const metadata = {
title: 'Browse Categories | Canadian Alternatives',
description: 'Browse American products and their Canadian alternatives by category',
};
export default function CategoriesPage() {
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight">
Browse by Category
</h1>
<p className="mt-3 max-w-md mx-auto text-lg text-gray-500 sm:text-xl md:mt-5 md:max-w-3xl">
Explore American products and their Canadian alternatives organized by category.
</p>
</div>
<Suspense fallback={<div className="text-center py-12">Loading categories...</div>}>
<CategoryList />
</Suspense>
</div>
</div>
);
}

View File

@ -1,26 +1,126 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.129 0.042 264.695);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.129 0.042 264.695);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.984 0.003 247.858);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.279 0.041 260.031);
--input: oklch(0.279 0.041 260.031);
--ring: oklch(0.446 0.043 257.281);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(0.279 0.041 260.031);
--sidebar-ring: oklch(0.446 0.043 257.281);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { NavigationBar } from "@/components/navigation-bar";
import { Footer } from "@/components/footer";
import { NuqsAdapter } from 'nuqs/adapters/next/app';
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +16,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Canadian Alternatives",
description: "Find Canadian alternatives to American products",
keywords: "Canadian products, Made in Canada, Canadian alternatives, Buy Canadian",
};
export default function RootLayout({
@ -25,9 +29,13 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
>
{children}
<NuqsAdapter>
<NavigationBar />
<main className="flex-grow">{children}</main>
<Footer />
</NuqsAdapter>
</body>
</html>
);

View File

@ -0,0 +1,57 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { getAllCategories, getOpportunities } from '@/lib/services/products';
export async function OpportunityList() {
// Get all unique categories
const categories = await getAllCategories();
// For each category, get opportunities
const opportunitiesByCategory = await Promise.all(
categories.map(async (category) => {
const opportunities = await getOpportunities(category);
return { category, opportunities };
})
);
// Filter out categories with no opportunities
const categoriesWithOpportunities = opportunitiesByCategory.filter(
({ opportunities }) => opportunities.length > 0
);
if (categoriesWithOpportunities.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No business opportunities have been suggested yet. Be the first to suggest one!</p>
</div>
);
}
return (
<div className="space-y-8">
{categoriesWithOpportunities.map(({ category, opportunities }) => (
<div key={category}>
<h3 className="text-xl font-semibold mb-4 flex items-center">
<Badge variant="outline" className="mr-2">{category}</Badge>
<span>Opportunities</span>
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{opportunities.map((opportunity) => (
<Card key={opportunity.id}>
<CardHeader className="pb-2">
<CardTitle className="text-lg">{category}</CardTitle>
<CardDescription>
Posted on {new Date(opportunity.created_at).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-gray-700">{opportunity.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,36 @@
import { Suspense } from 'react';
import { OpportunityForm } from '@/components/opportunity-form';
import { OpportunityList } from './opportunity-list';
export const metadata = {
title: 'Business Opportunities | Canadian Alternatives',
description: 'Discover business opportunities for Canadian entrepreneurs to create alternatives to American products',
};
export default function OpportunitiesPage() {
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight">
Business Opportunities
</h1>
<p className="mt-3 max-w-md mx-auto text-lg text-gray-500 sm:text-xl md:mt-5 md:max-w-3xl">
Discover product categories where Canadian alternatives are needed and suggest new opportunities.
</p>
</div>
<div className="mb-16">
<OpportunityForm />
</div>
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Current Opportunities</h2>
<Suspense fallback={<div className="text-center py-12">Loading opportunities...</div>}>
<OpportunityList />
</Suspense>
</div>
</div>
</div>
);
}

View File

@ -1,101 +1,70 @@
import Image from "next/image";
import { ProductSearch } from '@/components/product-search';
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
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"
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl">
Canadian Alternatives
</h1>
<p className="mt-3 max-w-md mx-auto text-lg text-gray-500 sm:text-xl md:mt-5 md:max-w-3xl">
Find Canadian-made alternatives to your favorite American products. Support local businesses and reduce your carbon footprint.
</p>
</div>
<div className="mb-16">
<ProductSearch />
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-lg mb-12">
<div className="px-4 py-5 sm:px-6">
<h2 className="text-2xl font-bold text-gray-900">Why Choose Canadian?</h2>
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Support Local Economy</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
When you buy Canadian products, you're supporting Canadian jobs, businesses, and communities.
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Environmental Impact</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
Local products often have a smaller carbon footprint due to reduced transportation distances.
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Quality & Standards</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
Canadian products are manufactured under high standards for quality, safety, and labor conditions.
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Economic Independence</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
Buying Canadian helps reduce dependency on foreign imports and strengthens our economic resilience.
</dd>
</div>
</dl>
</div>
</div>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Can't Find a Canadian Alternative?</h2>
<p className="mb-6 text-gray-600">
We maintain a list of product categories that need Canadian alternatives. These represent opportunities for entrepreneurs!
</p>
<a
href="/opportunities"
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
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"
>
Read our docs
View Business Opportunities
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
</div>
);
}

36
src/app/search/page.tsx Normal file
View File

@ -0,0 +1,36 @@
import { Suspense } from 'react';
import { ProductSearch } from '@/components/product-search';
import { ProductResults } from './product-results';
export default function SearchPage({
searchParams,
}: {
searchParams: { q?: string };
}) {
const query = searchParams.q || '';
return (
<div className="py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<ProductSearch />
</div>
{query ? (
<>
<h2 className="text-xl font-semibold mb-6">
Search results for "{query}"
</h2>
<Suspense fallback={<div className="text-center py-12">Loading results...</div>}>
<ProductResults query={query} />
</Suspense>
</>
) : (
<div className="text-center py-12">
<p className="text-gray-500">Enter a search term to find American products and their Canadian alternatives.</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { ProductCard } from '@/components/product-card';
import { searchProducts } from '@/lib/services/products';
interface ProductResultsProps {
query: string;
}
export async function ProductResults({ query }: ProductResultsProps) {
const products = await searchProducts(query);
if (products.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-700 mb-4">No products found for "{query}".</p>
<p className="text-gray-500">
This might represent an opportunity for Canadian entrepreneurs!
<a href="/opportunities" className="text-red-600 hover:underline ml-1">
View business opportunities
</a>
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

View File

@ -0,0 +1,40 @@
import Image from 'next/image';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { type Alternative } from '@/lib/services/products';
interface AlternativeCardProps {
alternative: Alternative;
}
export function AlternativeCard({ alternative }: AlternativeCardProps) {
return (
<Card className="overflow-hidden">
<CardHeader className="p-4">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{alternative.name}</CardTitle>
<CardDescription>{alternative.company}</CardDescription>
</div>
<Badge className="ml-2 bg-red-100 text-red-800 hover:bg-red-100">Canadian</Badge>
</div>
</CardHeader>
{alternative.image_url && (
<div className="relative w-full h-48">
<Image
src={alternative.image_url}
alt={alternative.name}
fill
className="object-cover"
/>
</div>
)}
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{alternative.description}</p>
</CardContent>
<CardFooter className="p-4 pt-0">
<Badge variant="secondary">{alternative.category}</Badge>
</CardFooter>
</Card>
);
}

40
src/components/footer.tsx Normal file
View File

@ -0,0 +1,40 @@
import Link from 'next/link';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-gray-50 border-t border-gray-200">
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
<nav className="flex flex-wrap justify-center">
<div className="px-5 py-2">
<Link href="/about" className="text-base text-gray-500 hover:text-gray-900">
About
</Link>
</div>
<div className="px-5 py-2">
<Link href="/privacy" className="text-base text-gray-500 hover:text-gray-900">
Privacy
</Link>
</div>
<div className="px-5 py-2">
<Link href="/terms" className="text-base text-gray-500 hover:text-gray-900">
Terms
</Link>
</div>
<div className="px-5 py-2">
<Link href="/contact" className="text-base text-gray-500 hover:text-gray-900">
Contact
</Link>
</div>
</nav>
<p className="mt-8 text-center text-base text-gray-500">
&copy; {currentYear} Canadian Alternatives. All rights reserved.
</p>
<p className="mt-2 text-center text-sm text-gray-400">
Supporting Canadian businesses and entrepreneurs.
</p>
</div>
</footer>
);
}

View File

@ -0,0 +1,70 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
const navItems = [
{ label: 'Home', href: '/' },
{ label: 'Browse Categories', href: '/categories' },
{ label: 'Business Opportunities', href: '/opportunities' },
{ label: 'About', href: '/about' },
];
export function NavigationBar() {
const pathname = usePathname();
return (
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Link href="/" className="flex items-center">
<span className="text-xl font-bold text-red-600">
Canadian Alternatives
</span>
</Link>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
pathname === item.href
? 'border-red-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
)}
>
{item.label}
</Link>
))}
</div>
</div>
</div>
</div>
{/* Mobile menu */}
<div className="sm:hidden py-2 border-t border-gray-200">
<div className="grid grid-cols-4 gap-1 px-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'text-center text-xs py-2 px-1 rounded-md font-medium',
pathname === item.href
? 'bg-red-50 text-red-700'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'
)}
>
{item.label}
</Link>
))}
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,116 @@
'use client';
import { useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { addOpportunity } from '@/lib/services/products';
const opportunitySchema = z.object({
category: z.string().min(2, 'Category must be at least 2 characters'),
description: z.string().min(10, 'Description must be at least 10 characters').max(500, 'Description must be less than 500 characters')
});
type OpportunityFormValues = z.infer<typeof opportunitySchema>;
export function OpportunityForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const form = useForm<OpportunityFormValues>({
resolver: zodResolver(opportunitySchema),
defaultValues: {
category: '',
description: ''
}
});
async function onSubmit(data: OpportunityFormValues) {
setIsSubmitting(true);
setIsSuccess(false);
try {
const success = await addOpportunity(data.category, data.description);
if (success) {
setIsSuccess(true);
form.reset();
}
} catch (error) {
console.error('Error submitting opportunity:', error);
} finally {
setIsSubmitting(false);
}
}
return (
<div className="w-full max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold">Suggest a Business Opportunity</h2>
<p className="text-muted-foreground">
Do you see a gap in the Canadian market? Suggest a product category for entrepreneurs.
</p>
</div>
{isSuccess && (
<div className="bg-green-50 border border-green-200 text-green-800 rounded-md p-4 mb-6">
Thank you for your suggestion! Your business opportunity has been added.
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Product Category</FormLabel>
<FormControl>
<Input placeholder="e.g., Electronics, Food, Clothing" {...field} />
</FormControl>
<FormDescription>
The general product category for this opportunity
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Business Opportunity Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe the opportunity and why you think it would be successful in Canada..."
className="min-h-24"
{...field}
/>
</FormControl>
<FormDescription>
Provide details about the market gap and potential for Canadian entrepreneurs
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full md:w-auto"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Opportunity'}
</Button>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,45 @@
import Image from 'next/image';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { type Product } from '@/lib/services/products';
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
return (
<Card className="overflow-hidden">
<CardHeader className="p-4">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{product.name}</CardTitle>
<CardDescription>{product.company}</CardDescription>
</div>
<Badge variant="outline" className="ml-2">{product.country_of_origin}</Badge>
</div>
</CardHeader>
{product.image_url && (
<div className="relative w-full h-48">
<Image
src={product.image_url}
alt={product.name}
fill
className="object-cover"
/>
</div>
)}
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{product.description}</p>
</CardContent>
<CardFooter className="p-4 pt-0 flex justify-between">
<Badge variant="secondary">{product.category}</Badge>
<Link href={`/alternatives/${product.id}`}>
<Button variant="default" size="sm">View Alternatives</Button>
</Link>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQueryState } from 'nuqs';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
const searchSchema = z.object({
query: z.string().min(2, 'Search query must be at least 2 characters')
});
type SearchFormValues = z.infer<typeof searchSchema>;
export function ProductSearch() {
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);
const [searchQuery, setSearchQuery] = useQueryState('q');
const form = useForm<SearchFormValues>({
resolver: zodResolver(searchSchema),
defaultValues: {
query: searchQuery || ''
}
});
async function onSubmit(data: SearchFormValues) {
setIsSearching(true);
await setSearchQuery(data.query);
setIsSearching(false);
}
return (
<div className="w-full max-w-2xl mx-auto">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>Search American Products</FormLabel>
<FormControl>
<div className="flex space-x-2">
<Input
placeholder="Enter product name (e.g., Coca-Cola, Nike shoes)"
{...field}
className="flex-1"
/>
<Button type="submit" disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search'}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,58 @@
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 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,68 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,177 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive-foreground", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive-foreground text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,181 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

139
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

116
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,81 @@
import { supabase, Tables } from '../supabase';
export type Product = Tables['products'];
export type Alternative = Tables['alternatives'];
export type Opportunity = Tables['opportunities'];
export async function searchProducts(query: string): Promise<Product[]> {
const { data, error } = await supabase
.from('products')
.select('*')
.ilike('name', `%${query}%`)
.order('name');
if (error) {
console.error('Error searching products:', error);
return [];
}
return data;
}
export async function getAlternatives(productId: string): Promise<Alternative[]> {
const { data, error } = await supabase
.from('alternatives')
.select('*')
.eq('product_id', productId)
.order('name');
if (error) {
console.error('Error getting alternatives:', error);
return [];
}
return data;
}
export async function getOpportunities(category: string): Promise<Opportunity[]> {
const { data, error } = await supabase
.from('opportunities')
.select('*')
.eq('category', category)
.order('created_at', { ascending: false });
if (error) {
console.error('Error getting opportunities:', error);
return [];
}
return data;
}
export async function addOpportunity(category: string, description: string): Promise<boolean> {
const { error } = await supabase
.from('opportunities')
.insert([
{ category, description }
]);
if (error) {
console.error('Error adding opportunity:', error);
return false;
}
return true;
}
export async function getAllCategories(): Promise<string[]> {
const { data, error } = await supabase
.from('products')
.select('category')
.order('category');
if (error) {
console.error('Error getting categories:', error);
return [];
}
// Extract unique categories
const categories = [...new Set(data.map(item => item.category))];
return categories;
}

35
src/lib/supabase.ts Normal file
View File

@ -0,0 +1,35 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export type Tables = {
products: {
id: string;
name: string;
description: string;
company: string;
country_of_origin: string;
category: string;
image_url?: string;
created_at: string;
};
alternatives: {
id: string;
product_id: string;
name: string;
description: string;
company: string;
category: string;
image_url?: string;
created_at: string;
};
opportunities: {
id: string;
category: string;
description: string;
created_at: string;
};
};

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

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