diff --git a/database-setup.sql b/database-setup.sql
new file mode 100644
index 0000000..a7f3e75
--- /dev/null
+++ b/database-setup.sql
@@ -0,0 +1,177 @@
+-- Create extensions
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Create tables
+CREATE TABLE IF NOT EXISTS products (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ company TEXT NOT NULL,
+ country_of_origin TEXT NOT NULL,
+ category TEXT NOT NULL,
+ image_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS alternatives (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ company TEXT NOT NULL,
+ category TEXT NOT NULL,
+ image_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS opportunities (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ category TEXT NOT NULL,
+ description TEXT NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Create profiles table for user authentication
+CREATE TABLE IF NOT EXISTS profiles (
+ id UUID PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ full_name TEXT,
+ avatar_url TEXT,
+ is_admin BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Set up Row Level Security (RLS)
+-- Enable RLS on tables
+ALTER TABLE products ENABLE ROW LEVEL SECURITY;
+ALTER TABLE alternatives ENABLE ROW LEVEL SECURITY;
+ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
+ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
+
+-- Create policies
+-- Products: everyone can view, only authenticated users can insert, only admins can update/delete
+CREATE POLICY "Products are viewable by everyone" ON products
+ FOR SELECT USING (true);
+
+CREATE POLICY "Products can be inserted by authenticated users" ON products
+ FOR INSERT WITH CHECK (auth.role() = 'authenticated');
+
+CREATE POLICY "Products can be updated by admins" ON products
+ FOR UPDATE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+CREATE POLICY "Products can be deleted by admins" ON products
+ FOR DELETE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+-- Alternatives: everyone can view, only authenticated users can insert, only admins can update/delete
+CREATE POLICY "Alternatives are viewable by everyone" ON alternatives
+ FOR SELECT USING (true);
+
+CREATE POLICY "Alternatives can be inserted by authenticated users" ON alternatives
+ FOR INSERT WITH CHECK (auth.role() = 'authenticated');
+
+CREATE POLICY "Alternatives can be updated by admins" ON alternatives
+ FOR UPDATE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+CREATE POLICY "Alternatives can be deleted by admins" ON alternatives
+ FOR DELETE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+-- Opportunities: everyone can view, only authenticated users can insert, only admins can update/delete
+CREATE POLICY "Opportunities are viewable by everyone" ON opportunities
+ FOR SELECT USING (true);
+
+CREATE POLICY "Opportunities can be inserted by authenticated users" ON opportunities
+ FOR INSERT WITH CHECK (auth.role() = 'authenticated');
+
+CREATE POLICY "Opportunities can be updated by admins" ON opportunities
+ FOR UPDATE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+CREATE POLICY "Opportunities can be deleted by admins" ON opportunities
+ FOR DELETE USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+-- Profiles: users can view all profiles, but can only update their own
+CREATE POLICY "Profiles are viewable by everyone" ON profiles
+ FOR SELECT USING (true);
+
+CREATE POLICY "Users can insert their own profile" ON profiles
+ FOR INSERT WITH CHECK (auth.uid() = id);
+
+CREATE POLICY "Users can update their own profile" ON profiles
+ FOR UPDATE USING (auth.uid() = id);
+
+-- Create triggers to update updated_at column
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_profiles_updated_at
+BEFORE UPDATE ON profiles
+FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- Create categories table to standardize categories
+CREATE TABLE IF NOT EXISTS categories (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Allow everyone to view categories
+ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Categories are viewable by everyone" ON categories
+ FOR SELECT USING (true);
+CREATE POLICY "Categories can be managed by admins" ON categories
+ FOR ALL USING (
+ auth.role() = 'authenticated' AND
+ EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_admin = true)
+ );
+
+-- Insert some common categories
+INSERT INTO categories (name) VALUES
+ ('Electronics'),
+ ('Food & Beverage'),
+ ('Clothing'),
+ ('Software'),
+ ('Home Goods'),
+ ('Personal Care'),
+ ('Automotive'),
+ ('Entertainment')
+ON CONFLICT (name) DO NOTHING;
+
+-- Function to add an admin user
+CREATE OR REPLACE FUNCTION create_admin_user(email TEXT, password TEXT, full_name TEXT)
+RETURNS uuid AS $$
+DECLARE
+ user_id uuid;
+BEGIN
+ -- Create user in auth.users via Supabase's auth.users() function (handled by Supabase Auth)
+ -- This function doesn't exist in pure PostgreSQL, it's a placeholder for the actual implementation
+ -- You'll need to create the admin user through the Supabase dashboard or API
+ -- After creating the user, insert into profiles with is_admin = true
+ INSERT INTO profiles (id, email, full_name, is_admin)
+ VALUES (user_id, email, full_name, true);
+
+ RETURN user_id;
+END;
+$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 9ade907..ab08ceb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,11 +12,15 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
+ "@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@supabase/supabase-js": "^2.49.1",
+ "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -24,6 +28,7 @@
"lucide-react": "^0.477.0",
"next": "15.2.1",
"nuqs": "^2.4.0",
+ "postcss": "^8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
@@ -33,13 +38,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.0.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.1",
- "tailwindcss": "^4",
+ "tailwindcss": "^4.0.9",
"typescript": "^5"
}
},
@@ -1100,6 +1105,35 @@
}
}
},
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz",
+ "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-menu": "2.1.6",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
@@ -1181,6 +1215,46 @@
}
}
},
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz",
+ "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-collection": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
+ "@radix-ui/react-focus-guards": "1.1.1",
+ "@radix-ui/react-focus-scope": "1.1.2",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.2",
+ "@radix-ui/react-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-roving-focus": "1.1.2",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -1284,6 +1358,37 @@
}
}
},
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
+ "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-collection": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz",
@@ -1327,6 +1432,29 @@
}
}
},
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
+ "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
@@ -1345,6 +1473,40 @@
}
}
},
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
+ "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-collection": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
+ "@radix-ui/react-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.0",
+ "@radix-ui/react-visually-hidden": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
@@ -2431,6 +2593,43 @@
"node": ">= 0.4"
}
},
+ "node_modules/autoprefixer": {
+ "version": "10.4.20",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
+ "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.23.3",
+ "caniuse-lite": "^1.0.30001646",
+ "fraction.js": "^4.3.7",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.0.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2498,6 +2697,38 @@
"node": ">=8"
}
},
+ "node_modules/browserslist": {
+ "version": "4.24.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
+ "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001688",
+ "electron-to-chromium": "^1.5.73",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.1"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3254,6 +3485,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.112",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz",
+ "integrity": "sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA==",
+ "license": "ISC"
+ },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -3449,6 +3686,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -4028,6 +4274,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fraction.js": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/framer-motion": {
"version": "12.4.10",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.10.tgz",
@@ -5418,6 +5677,21 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-releases": {
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "license": "MIT"
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/nuqs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.4.0.tgz",
@@ -5711,7 +5985,6 @@
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -5736,6 +6009,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "license": "MIT"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6781,6 +7060,36 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
diff --git a/package.json b/package.json
index 5d32cc5..d8c882a 100644
--- a/package.json
+++ b/package.json
@@ -13,11 +13,15 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
+ "@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@supabase/supabase-js": "^2.49.1",
+ "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -25,6 +29,7 @@
"lucide-react": "^0.477.0",
"next": "15.2.1",
"nuqs": "^2.4.0",
+ "postcss": "^8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
@@ -34,13 +39,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
- "@tailwindcss/postcss": "^4",
+ "@tailwindcss/postcss": "^4.0.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.1",
- "tailwindcss": "^4",
+ "tailwindcss": "^4.0.9",
"typescript": "^5"
}
}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..d54874f
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ plugins: {
+ 'tailwindcss/nesting': {},
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
\ No newline at end of file
diff --git a/public/maple-leaf.svg b/public/maple-leaf.svg
new file mode 100644
index 0000000..fc9af8e
--- /dev/null
+++ b/public/maple-leaf.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index 0b9911e..c6af12c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,126 +1,73 @@
-@import "tailwindcss";
-
-@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);
-}
-
-: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);
-}
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
@layer base {
- * {
- @apply border-border outline-ring/50;
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+ --primary: 0 72.2% 50.6%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 0 72.2% 50.6%;
+ --radius: 0.5rem;
}
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 0 72.2% 50.6%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 0 72.2% 50.6%;
+ }
+
+ * {
+ @apply border-border;
+ }
+
body {
@apply bg-background text-foreground;
}
}
+
+@layer utilities {
+ .maple-leaf-pattern {
+ background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 0l4 8 8-4-4 8 8 4-8 4 4 8-8-4-4 8-4-8-8 4 4-8-8-4 8-4-4-8 8 4z' fill='%23EF4444' fill-opacity='0.1' fill-rule='evenodd'/%3E%3C/svg%3E");
+ }
+
+ .canadian-shadow {
+ box-shadow: 0 4px 6px -1px rgba(239, 68, 68, 0.1),
+ 0 2px 4px -1px rgba(239, 68, 68, 0.06);
+ }
+
+ .canadian-gradient {
+ background: linear-gradient(135deg, #EF4444 0%, #991B1B 100%);
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5dfab3b..8fff25d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,41 +1,56 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter } 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",
- subsets: ["latin"],
-});
+import { Header } from "@/components/ui/header";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/toaster";
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Canadian Alternatives",
- description: "Find Canadian alternatives to American products",
- keywords: "Canadian products, Made in Canada, Canadian alternatives, Buy Canadian",
+ description: "Discover Canadian alternatives to international products",
};
export default function RootLayout({
children,
-}: Readonly<{
+}: {
children: React.ReactNode;
-}>) {
+}) {
return (
-
-
-
-
- {children}
-
-
+
+
+
+ {/* Canadian-themed background pattern */}
+
+
+
+
+
+ {children}
+
+
+
+
+
+ © {new Date().getFullYear()} Canadian Alternatives. All rights reserved.
+
+
+ 🍁
+ Made in Canada
+
+
+
+
+
+
+
);
diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx
new file mode 100644
index 0000000..69e3d02
--- /dev/null
+++ b/src/app/products/page.tsx
@@ -0,0 +1,39 @@
+import { ProductForm } from '@/components/products/product-form';
+import { ProductList } from '@/components/products/product-list';
+import { Separator } from '@/components/ui/separator';
+
+export default function ProductsPage() {
+ return (
+
+
+ {/* Canadian-themed background pattern */}
+
+
+
+
Products
+
+ Discover and add Canadian alternatives to international products
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx
new file mode 100644
index 0000000..7f08d16
--- /dev/null
+++ b/src/components/auth/auth-form.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useForm } from 'react-hook-form'
+import * as z from 'zod'
+import { supabase } from '@/lib/supabase'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { toast } from '@/components/ui/use-toast'
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
+
+const signInSchema = z.object({
+ email: z.string().email({ message: 'Please enter a valid email address.' }),
+ password: z.string().min(6, {
+ message: 'Password must be at least 6 characters.',
+ }),
+})
+
+const signUpSchema = z.object({
+ email: z.string().email({ message: 'Please enter a valid email address.' }),
+ password: z.string().min(6, {
+ message: 'Password must be at least 6 characters.',
+ }),
+ fullName: z.string().min(2, {
+ message: 'Full name must be at least 2 characters.',
+ }),
+})
+
+const resetPasswordSchema = z.object({
+ email: z.string().email({ message: 'Please enter a valid email address.' }),
+})
+
+type FormValues = z.infer
+
+export function AuthForm() {
+ const [activeTab, setActiveTab] = useState('signin')
+ const [loading, setLoading] = useState(false)
+ const router = useRouter()
+
+ const signInForm = useForm>({
+ resolver: zodResolver(signInSchema),
+ defaultValues: {
+ email: '',
+ password: '',
+ },
+ })
+
+ const signUpForm = useForm>({
+ resolver: zodResolver(signUpSchema),
+ defaultValues: {
+ email: '',
+ password: '',
+ fullName: '',
+ },
+ })
+
+ const resetPasswordForm = useForm>({
+ resolver: zodResolver(resetPasswordSchema),
+ defaultValues: {
+ email: '',
+ },
+ })
+
+ async function onSignIn(values: z.infer) {
+ try {
+ setLoading(true)
+ const { error } = await supabase.auth.signInWithPassword({
+ email: values.email,
+ password: values.password,
+ })
+
+ if (error) {
+ throw error
+ }
+
+ toast({
+ title: 'Sign in successful',
+ description: 'You have been signed in to your account.',
+ })
+
+ router.push('/dashboard')
+ router.refresh()
+ } catch (error: any) {
+ toast({
+ title: 'Error signing in',
+ description: error.message,
+ variant: 'destructive',
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function onSignUp(values: z.infer) {
+ try {
+ setLoading(true)
+
+ // Create user account
+ const { data: authData, error: authError } = await supabase.auth.signUp({
+ email: values.email,
+ password: values.password,
+ options: {
+ data: {
+ full_name: values.fullName,
+ },
+ },
+ })
+
+ if (authError) {
+ throw authError
+ }
+
+ // Create profile record
+ if (authData.user) {
+ const { error: profileError } = await supabase
+ .from('profiles')
+ .insert({
+ id: authData.user.id,
+ email: values.email,
+ full_name: values.fullName,
+ })
+
+ if (profileError) {
+ throw profileError
+ }
+ }
+
+ toast({
+ title: 'Account created',
+ description: 'Please check your email to confirm your account.',
+ })
+
+ // Switch to sign in tab
+ setActiveTab('signin')
+ } catch (error: any) {
+ toast({
+ title: 'Error creating account',
+ description: error.message,
+ variant: 'destructive',
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function onResetPassword(values: z.infer) {
+ try {
+ setLoading(true)
+
+ const { error } = await supabase.auth.resetPasswordForEmail(values.email, {
+ redirectTo: `${window.location.origin}/auth/update-password`,
+ })
+
+ if (error) {
+ throw error
+ }
+
+ toast({
+ title: 'Password reset email sent',
+ description: 'Please check your email for password reset instructions.',
+ })
+
+ // Switch to sign in tab
+ setActiveTab('signin')
+ } catch (error: any) {
+ toast({
+ title: 'Error sending reset email',
+ description: error.message,
+ variant: 'destructive',
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+ Welcome to Canadian Alternatives
+
+ Sign in to your account or create a new one
+
+
+
+
+
+ Sign In
+ Sign Up
+ Reset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ By continuing, you agree to our Terms of Service and Privacy Policy.
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/auth/auth-provider.tsx b/src/components/auth/auth-provider.tsx
new file mode 100644
index 0000000..c99dbee
--- /dev/null
+++ b/src/components/auth/auth-provider.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import { createContext, useContext, useEffect, useState } from 'react'
+import { Session, User } from '@supabase/supabase-js'
+import { useRouter } from 'next/navigation'
+import { supabase } from '@/lib/supabase'
+
+type AuthContextType = {
+ user: User | null
+ session: Session | null
+ isLoading: boolean
+ signOut: () => Promise
+}
+
+const AuthContext = createContext(undefined)
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null)
+ const [session, setSession] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const router = useRouter()
+
+ useEffect(() => {
+ async function getSession() {
+ setIsLoading(true)
+ try {
+ const { data: { session }, error } = await supabase.auth.getSession()
+ if (error) {
+ throw error
+ }
+
+ setSession(session)
+ setUser(session?.user || null)
+
+ const { data: authListener } = supabase.auth.onAuthStateChange(
+ async (event, session) => {
+ console.log(`Auth event: ${event}`)
+ setSession(session)
+ setUser(session?.user || null)
+ router.refresh()
+ }
+ )
+
+ return () => {
+ authListener?.subscription.unsubscribe()
+ }
+ } catch (error) {
+ console.error('Error getting session:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const unsubscribe = getSession()
+
+ return () => {
+ if (typeof unsubscribe === 'function') {
+ unsubscribe()
+ }
+ }
+ }, [router])
+
+ const signOut = async () => {
+ try {
+ await supabase.auth.signOut()
+ setUser(null)
+ setSession(null)
+ } catch (error) {
+ console.error('Error signing out:', error)
+ }
+ }
+
+ const value = {
+ user,
+ session,
+ isLoading,
+ signOut,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useAuth = () => {
+ const context = useContext(AuthContext)
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider')
+ }
+ return context
+}
\ No newline at end of file
diff --git a/src/components/auth/user-account-nav.tsx b/src/components/auth/user-account-nav.tsx
new file mode 100644
index 0000000..f91b9cb
--- /dev/null
+++ b/src/components/auth/user-account-nav.tsx
@@ -0,0 +1,104 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import { User } from '@supabase/supabase-js'
+import { supabase } from '@/lib/supabase'
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Button } from '@/components/ui/button'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { toast } from '@/components/ui/use-toast'
+
+interface UserAccountNavProps {
+ user: User | null
+}
+
+export function UserAccountNav({ user }: UserAccountNavProps) {
+ const router = useRouter()
+
+ const getInitials = (name: string) => {
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ }
+
+ const handleSignOut = async () => {
+ try {
+ await supabase.auth.signOut()
+ toast({
+ title: 'Signed out',
+ description: 'You have been signed out of your account.',
+ })
+ router.push('/')
+ router.refresh()
+ } catch (error: any) {
+ toast({
+ title: 'Error signing out',
+ description: error.message,
+ variant: 'destructive',
+ })
+ }
+ }
+
+ if (!user) {
+ return (
+
+ Sign In
+
+ )
+ }
+
+ // Get user metadata
+ const fullName = user.user_metadata?.full_name || 'User'
+ const email = user.email || ''
+ const avatarUrl = user.user_metadata?.avatar_url
+
+ return (
+
+
+
+
+
+ {getInitials(fullName)}
+
+
+
+
+
+
{fullName}
+
+ {email}
+
+
+
+
+ Dashboard
+
+
+ Profile
+
+ {user.app_metadata.admin && (
+
+ Admin
+
+ )}
+
+
+ Sign out
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/products/product-form.tsx b/src/components/products/product-form.tsx
new file mode 100644
index 0000000..1daf6e0
--- /dev/null
+++ b/src/components/products/product-form.tsx
@@ -0,0 +1,235 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { motion } from 'framer-motion';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { Button } from '@/components/ui/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { useToast } from '@/components/ui/use-toast';
+import { supabase } from '@/lib/supabase';
+import { PRODUCT_CATEGORIES, ProductCategory } from '@/types/database';
+
+const productSchema = z.object({
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+ description: z.string().min(10, 'Description must be at least 10 characters'),
+ company: z.string().min(2, 'Company name must be at least 2 characters'),
+ country_of_origin: z.string().min(2, 'Country must be at least 2 characters'),
+ category: z.custom(),
+ image_url: z.string().url('Must be a valid URL').optional(),
+});
+
+type ProductFormValues = z.infer;
+
+export function ProductForm() {
+ const { toast } = useToast();
+ const form = useForm({
+ resolver: zodResolver(productSchema),
+ defaultValues: {
+ name: '',
+ description: '',
+ company: '',
+ country_of_origin: 'Canada',
+ image_url: '',
+ },
+ });
+
+ async function onSubmit(data: ProductFormValues) {
+ try {
+ const { error } = await supabase.from('products').insert(data);
+
+ if (error) throw error;
+
+ toast({
+ title: 'Success!',
+ description: 'Product has been added successfully.',
+ });
+
+ form.reset();
+ } catch (error) {
+ console.error('Error adding product:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to add product. Please try again.',
+ variant: 'destructive',
+ });
+ }
+ }
+
+ return (
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/products/product-list.tsx b/src/components/products/product-list.tsx
new file mode 100644
index 0000000..b003fad
--- /dev/null
+++ b/src/components/products/product-list.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useEffect, useState } from 'react';
+
+import { PRODUCT_CATEGORIES } from '@/types/database';
+import type { Product } from '@/types/database';
+import { supabase } from '@/lib/supabase';
+
+export function ProductList() {
+ const [products, setProducts] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchProducts() {
+ try {
+ const { data, error } = await supabase
+ .from('products')
+ .select('*')
+ .order('created_at', { ascending: false });
+
+ if (error) throw error;
+ setProducts(data || []);
+ } catch (error) {
+ console.error('Error fetching products:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ fetchProducts();
+ }, []);
+
+ if (isLoading) {
+ return (
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {products.map((product, index) => {
+ const category = PRODUCT_CATEGORIES.find(
+ (c) => c.value === product.category
+ );
+
+ return (
+
+ {product.image_url ? (
+
+
+
+ ) : (
+
+ {category?.icon || '📦'}
+
+ )}
+
+
+
+ {category?.icon}
+
+ {category?.label || product.category}
+
+
+
+
+ {product.name}
+
+
+
+ {product.description}
+
+
+
+ {product.company}
+ {product.country_of_origin}
+
+
+
+ );
+ })}
+
+ {products.length === 0 && (
+
+
🍁
+
+ No Products Yet
+
+
+ Be the first to add a Canadian alternative product!
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx
new file mode 100644
index 0000000..6033a3c
--- /dev/null
+++ b/src/components/theme-provider.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import * as React from "react";
+import { useEffect } from "react";
+
+type Theme = "dark" | "light" | "system";
+
+type ThemeProviderProps = {
+ children: React.ReactNode;
+ defaultTheme?: Theme;
+ storageKey?: string;
+};
+
+type ThemeProviderState = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+};
+
+const initialState: ThemeProviderState = {
+ theme: "system",
+ setTheme: () => null,
+};
+
+const ThemeProviderContext = React.createContext(initialState);
+
+export function ThemeProvider({
+ children,
+ defaultTheme = "system",
+ storageKey = "ui-theme",
+ ...props
+}: ThemeProviderProps) {
+ const [theme, setTheme] = React.useState(defaultTheme);
+
+ useEffect(() => {
+ const savedTheme = localStorage.getItem(storageKey) as Theme;
+ if (savedTheme) {
+ setTheme(savedTheme);
+ }
+ }, [storageKey]);
+
+ useEffect(() => {
+ const root = window.document.documentElement;
+
+ root.classList.remove("light", "dark");
+
+ if (theme === "system") {
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
+ .matches
+ ? "dark"
+ : "light";
+
+ root.classList.add(systemTheme);
+ root.dataset.theme = systemTheme;
+ return;
+ }
+
+ root.classList.add(theme);
+ root.dataset.theme = theme;
+ }, [theme]);
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme);
+ setTheme(theme);
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTheme = () => {
+ const context = React.useContext(ThemeProviderContext);
+
+ if (context === undefined)
+ throw new Error("useTheme must be used within a ThemeProvider");
+
+ return context;
+};
\ No newline at end of file
diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..9a084d0
--- /dev/null
+++ b/src/components/theme-toggle.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import * as React from "react";
+import { Moon, Sun } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useTheme } from "@/components/theme-provider";
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ Toggle theme
+
+
+
+ setTheme("light")}>
+
+ Light
+
+ setTheme("dark")}>
+
+ Dark
+
+ setTheme("system")}>
+ 🖥️
+ System
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index cd0857a..2cc0028 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -5,26 +5,25 @@ 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",
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
- default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ default: "bg-primary text-primary-foreground 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",
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
- "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ "bg-secondary text-secondary-foreground 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",
+ 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: {
@@ -34,25 +33,24 @@ const buttonVariants = cva(
}
)
-function Button({
- className,
- variant,
- size,
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot : "button"
-
- return (
-
- )
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
}
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
export { Button, buttonVariants }
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..32800e6
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
\ No newline at end of file
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
index 2e847c1..ce264ae 100644
--- a/src/components/ui/form.tsx
+++ b/src/components/ui/form.tsx
@@ -10,7 +10,6 @@ import {
FieldValues,
FormProvider,
useFormContext,
- useFormState,
} from "react-hook-form"
import { cn } from "@/lib/utils"
@@ -20,7 +19,7 @@ const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath,
+ TName extends FieldPath = FieldPath
> = {
name: TName
}
@@ -31,7 +30,7 @@ const FormFieldContext = React.createContext(
const FormField = <
TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath,
+ TName extends FieldPath = FieldPath
>({
...props
}: ControllerProps) => {
@@ -45,8 +44,8 @@ const FormField = <
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
- const { getFieldState } = useFormContext()
- const formState = useFormState({ name: fieldContext.name })
+ const { getFieldState, formState } = useFormContext()
+
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
@@ -73,43 +72,46 @@ const FormItemContext = React.createContext(
{} as FormItemContextValue
)
-function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
const id = React.useId()
return (
-
+
)
-}
+})
+FormItem.displayName = "FormItem"
-function FormLabel({
- className,
- ...props
-}: React.ComponentProps) {
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
)
-}
+})
+FormLabel.displayName = "FormLabel"
-function FormControl({ ...props }: React.ComponentProps) {
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
) {
{...props}
/>
)
-}
+})
+FormControl.displayName = "FormControl"
-function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
)
-}
+})
+FormDescription.displayName = "FormDescription"
-function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
- const body = error ? String(error?.message ?? "") : props.children
+ const body = error ? String(error?.message) : children
if (!body) {
return null
@@ -145,15 +155,16 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return (
{body}
)
-}
+})
+FormMessage.displayName = "FormMessage"
export {
useFormField,
diff --git a/src/components/ui/header.tsx b/src/components/ui/header.tsx
new file mode 100644
index 0000000..ed50aa5
--- /dev/null
+++ b/src/components/ui/header.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import Image from 'next/image';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import { ThemeToggle } from '@/components/theme-toggle';
+
+const navigation = [
+ { name: 'Home', href: '/' },
+ { name: 'Products', href: '/products' },
+ { name: 'Alternatives', href: '/alternatives' },
+ { name: 'Opportunities', href: '/opportunities' },
+ { name: 'About', href: '/about' },
+];
+
+export function Header() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 4bfe9cc..677d05f 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -2,20 +2,24 @@ import * as React from "react"
import { cn } from "@/lib/utils"
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
- return (
-
- )
-}
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
index fb5fbc3..5341821 100644
--- a/src/components/ui/label.tsx
+++ b/src/components/ui/label.tsx
@@ -2,23 +2,25 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-function Label({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
export { Label }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 12f5b4c..39d9367 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -2,180 +2,161 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
-import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
-function Select({
- ...props
-}: React.ComponentProps) {
- return
-}
+const Select = SelectPrimitive.Root
-function SelectGroup({
- ...props
-}: React.ComponentProps) {
- return
-}
+const SelectGroup = SelectPrimitive.Group
-function SelectValue({
- ...props
-}: React.ComponentProps) {
- return
-}
+const SelectValue = SelectPrimitive.Value
-function SelectTrigger({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
- ,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
- {children}
-
-
-
-
- )
-}
-
-function SelectContent({
- className,
- children,
- position = "popper",
- ...props
-}: React.ComponentProps) {
- return (
-
-
+
{children}
-
-
- )
-}
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
-function SelectLabel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
-function SelectItem({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- )
-}
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
-function SelectSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
-function SelectScrollUpButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-function SelectScrollDownButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
- SelectContent,
SelectGroup,
- SelectItem,
- SelectLabel,
- SelectScrollDownButton,
- SelectScrollUpButton,
- SelectSeparator,
- SelectTrigger,
SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..7fa79e9
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
\ No newline at end of file
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index 376d86a..9f9a6dc 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -2,17 +2,23 @@ import * as React from "react"
import { cn } from "@/lib/utils"
-function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
- return (
-
- )
-}
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
export { Textarea }
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 0000000..c062ef6
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import * as ToastPrimitives from "@radix-ui/react-toast";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const ToastProvider = ToastPrimitives.Provider;
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ );
+});
+Toast.displayName = ToastPrimitives.Root.displayName;
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastAction.displayName = ToastPrimitives.Action.displayName;
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+ToastClose.displayName = ToastPrimitives.Close.displayName;
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastTitle.displayName = ToastPrimitives.Title.displayName;
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastDescription.displayName = ToastPrimitives.Description.displayName;
+
+type ToastProps = React.ComponentPropsWithoutRef;
+
+type ToastActionElement = React.ReactElement;
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+};
\ No newline at end of file
diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..ce225fe
--- /dev/null
+++ b/src/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast";
+import { useToast } from "@/components/ui/use-toast";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title} }
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts
new file mode 100644
index 0000000..cac24cc
--- /dev/null
+++ b/src/components/ui/use-toast.ts
@@ -0,0 +1,192 @@
+// Inspired by react-hot-toast library
+import * as React from "react";
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast";
+
+const TOAST_LIMIT = 1;
+const TOAST_REMOVE_DELAY = 1000000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+};
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const;
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_VALUE;
+ return count.toString();
+}
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map>();
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ };
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
+};
+
+const listeners: Array<(state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+type Toast = Omit;
+
+function toast({ ...props }: Toast) {
+ const id = genId();
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
+ },
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ };
+}
+
+export { useToast, toast };
\ No newline at end of file
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index a2cc9fc..f41ea4f 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -1,22 +1,33 @@
import { createClient } from '@supabase/supabase-js';
+import { Database } from '@/types/database';
-const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
-const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string;
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
-if (!supabaseUrl || !supabaseAnonKey) {
- throw new Error('Missing Supabase environment variables');
+if (!supabaseUrl) {
+ throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL');
+}
+if (!supabaseAnonKey) {
+ throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY');
}
-export const supabase = createClient(supabaseUrl, supabaseAnonKey);
+export const supabase = createClient(
+ supabaseUrl as string,
+ supabaseAnonKey as string
+);
-// For server-side functions that need admin privileges
-export const getServiceSupabase = () => {
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+// Helper function for server-side operations that require admin privileges
+export async function getSupabaseAdmin() {
if (!serviceRoleKey) {
- throw new Error('Missing service role key');
+ throw new Error('Missing env.SUPABASE_SERVICE_ROLE_KEY');
}
- return createClient(supabaseUrl, serviceRoleKey);
-};
+
+ return createClient(
+ supabaseUrl as string,
+ serviceRoleKey as string
+ );
+}
export type Tables = {
products: {
diff --git a/src/types/database.ts b/src/types/database.ts
new file mode 100644
index 0000000..7d0ee74
--- /dev/null
+++ b/src/types/database.ts
@@ -0,0 +1,73 @@
+export type ProductCategory =
+ | 'food'
+ | 'beverages'
+ | 'household'
+ | 'personal_care'
+ | 'clothing'
+ | 'electronics'
+ | 'other';
+
+export const PRODUCT_CATEGORIES: { label: string; value: ProductCategory; icon: string }[] = [
+ { label: 'Food', value: 'food', icon: '🍽️' },
+ { label: 'Beverages', value: 'beverages', icon: '🥤' },
+ { label: 'Household', value: 'household', icon: '🏠' },
+ { label: 'Personal Care', value: 'personal_care', icon: '🧴' },
+ { label: 'Clothing', value: 'clothing', icon: '👕' },
+ { label: 'Electronics', value: 'electronics', icon: '💻' },
+ { label: 'Other', value: 'other', icon: '📦' },
+];
+
+export interface Product {
+ id: string;
+ name: string;
+ description: string;
+ company: string;
+ country_of_origin: string;
+ category: ProductCategory;
+ image_url: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Alternative {
+ id: string;
+ product_id: string;
+ name: string;
+ description: string;
+ company: string;
+ category: ProductCategory;
+ image_url: string | null;
+ created_at: string;
+ updated_at: string;
+ product?: Product; // Optional relation
+}
+
+export interface Opportunity {
+ id: string;
+ category: ProductCategory;
+ description: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Database {
+ public: {
+ Tables: {
+ products: {
+ Row: Product;
+ Insert: Omit;
+ Update: Partial>;
+ };
+ alternatives: {
+ Row: Alternative;
+ Insert: Omit;
+ Update: Partial>;
+ };
+ opportunities: {
+ Row: Opportunity;
+ Insert: Omit;
+ Update: Partial>;
+ };
+ };
+ };
+}
\ No newline at end of file
diff --git a/supabase/migrations/20240320000000_initial_schema.sql b/supabase/migrations/20240320000000_initial_schema.sql
new file mode 100644
index 0000000..cf29a94
--- /dev/null
+++ b/supabase/migrations/20240320000000_initial_schema.sql
@@ -0,0 +1,94 @@
+-- Enable UUID extension
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Create enum for categories
+CREATE TYPE product_category AS ENUM (
+ 'food',
+ 'beverages',
+ 'household',
+ 'personal_care',
+ 'clothing',
+ 'electronics',
+ 'other'
+);
+
+-- Products table
+CREATE TABLE products (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ company TEXT NOT NULL,
+ country_of_origin TEXT NOT NULL,
+ category product_category NOT NULL,
+ image_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+-- Alternatives table
+CREATE TABLE alternatives (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ product_id UUID REFERENCES products(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ company TEXT NOT NULL,
+ category product_category NOT NULL,
+ image_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+-- Opportunities table
+CREATE TABLE opportunities (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ category product_category NOT NULL,
+ description TEXT NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+-- Create updated_at trigger function
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+-- Add triggers for updated_at
+CREATE TRIGGER update_products_updated_at
+ BEFORE UPDATE ON products
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_alternatives_updated_at
+ BEFORE UPDATE ON alternatives
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_opportunities_updated_at
+ BEFORE UPDATE ON opportunities
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Enable Row Level Security
+ALTER TABLE products ENABLE ROW LEVEL SECURITY;
+ALTER TABLE alternatives ENABLE ROW LEVEL SECURITY;
+ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
+
+-- Create policies
+CREATE POLICY "Enable read access for all users" ON products
+ FOR SELECT USING (true);
+
+CREATE POLICY "Enable read access for all users" ON alternatives
+ FOR SELECT USING (true);
+
+CREATE POLICY "Enable read access for all users" ON opportunities
+ FOR SELECT USING (true);
+
+-- Create indexes for better query performance
+CREATE INDEX idx_products_category ON products(category);
+CREATE INDEX idx_alternatives_product_id ON alternatives(product_id);
+CREATE INDEX idx_alternatives_category ON alternatives(category);
+CREATE INDEX idx_opportunities_category ON opportunities(category);
\ No newline at end of file
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..7bf10c7
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,99 @@
+import type { Config } from "tailwindcss";
+
+const config = {
+ darkMode: ["class", "[data-theme='dark']"],
+ content: [
+ './pages/**/*.{ts,tsx}',
+ './components/**/*.{ts,tsx}',
+ './app/**/*.{ts,tsx}',
+ './src/**/*.{ts,tsx}',
+ ],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ canadian: {
+ red: {
+ 50: "#FEF2F2",
+ 100: "#FEE2E2",
+ 200: "#FECACA",
+ 300: "#FCA5A5",
+ 400: "#F87171",
+ 500: "#EF4444",
+ 600: "#DC2626",
+ 700: "#B91C1C",
+ 800: "#991B1B",
+ 900: "#7F1D1D",
+ 950: "#450A0A",
+ },
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ "maple-leaf-spin": {
+ "0%": { transform: "rotate(0deg)" },
+ "100%": { transform: "rotate(360deg)" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ "maple-leaf-spin": "maple-leaf-spin 20s linear infinite",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+} satisfies Config;
+
+export default config;
\ No newline at end of file