From 91c68831bf4e21e9d263608f229c8a1ccf3919ac Mon Sep 17 00:00:00 2001 From: Sewmina Date: Sat, 20 Dec 2025 10:32:36 +0530 Subject: [PATCH] init --- .gitignore | 39 +++ README.md | 67 ++++ app/admin/page.tsx | 324 ++++++++++++++++++ app/api/drops/active/route.ts | 21 ++ app/api/drops/route.ts | 58 ++++ app/api/upload/route.ts | 66 ++++ app/components/Drop.tsx | 54 +++ app/components/Footer.tsx | 8 + app/components/InfoBox.tsx | 28 ++ app/components/Nav.tsx | 13 + app/components/PastDrops.tsx | 47 +++ app/components/Signup.tsx | 38 +++ app/globals.css | 436 ++++++++++++++++++++++++ app/layout.tsx | 23 ++ app/page.tsx | 38 +++ cbd420.sql | 159 +++++++++ index.html | 287 ++++++++++++++++ lib/db.ts | 15 + migrations/add_image_url.sql | 6 + next.config.js | 15 + package-lock.json | 621 ++++++++++++++++++++++++++++++++++ package.json | 23 ++ tsconfig.json | 28 ++ 23 files changed, 2414 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/admin/page.tsx create mode 100644 app/api/drops/active/route.ts create mode 100644 app/api/drops/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/components/Drop.tsx create mode 100644 app/components/Footer.tsx create mode 100644 app/components/InfoBox.tsx create mode 100644 app/components/Nav.tsx create mode 100644 app/components/PastDrops.tsx create mode 100644 app/components/Signup.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 cbd420.sql create mode 100644 index.html create mode 100644 lib/db.ts create mode 100644 migrations/add_image_url.sql create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b5a79c --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# uploaded files +/public/uploads + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff2c843 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# 420Deals.ch + +A premium collective buying platform for CBD in Switzerland. + +## Setup + +### Database + +1. Create the database using the provided SQL file: +```bash +mysql -u root -p < cbd420.sql +``` + +2. Run the migration to add image support (optional but recommended): +```bash +mysql -u root -p cbd420 < migrations/add_image_url.sql +``` + +3. Create a `.env.local` file in the root directory: +```env +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=your_password +DB_NAME=cbd420 +``` + +### Installation + +```bash +npm install +``` + +### Development + +```bash +npm run dev +``` + +Visit [http://localhost:3000](http://localhost:3000) for the main site and [http://localhost:3000/admin](http://localhost:3000/admin) for the admin panel. + +## Admin Panel + +Access the admin panel at `/admin` to: +- Create new drops +- View all drops +- Monitor drop progress and sold out status + +### Creating a Drop + +1. Navigate to `/admin` +2. Fill in the form: + - **Product Name**: e.g., "Harlequin – Collective Drop" + - **Batch Size**: Total amount (e.g., 1000) + - **Unit**: Custom unit (e.g., g, kg, ml, etc.) + - **Price Per Gram**: Price in CHF (e.g., 2.50) + - **Product Image**: Optional product image upload (JPEG, PNG, WebP, max 5MB) +3. Click "Create Drop" + +## Project Structure + +- `app/` - Next.js app directory + - `api/drops/` - API routes for drop management + - `admin/` - Admin panel page + - `components/` - React components +- `lib/db.ts` - Database connection pool +- `cbd420.sql` - Database schema diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..9b5ad3c --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,324 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' + +interface Drop { + id: number + item: string + size: number + fill: number + unit: string + ppu: number + created_at: string +} + +export default function AdminPage() { + const router = useRouter() + const [drops, setDrops] = useState([]) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [uploadingImage, setUploadingImage] = useState(false) + const [formData, setFormData] = useState({ + item: '', + size: '', + unit: 'g', + ppu: '', + imageFile: null as File | null, + imagePreview: '', + }) + + useEffect(() => { + fetchDrops() + }, []) + + const fetchDrops = async () => { + try { + const response = await fetch('/api/drops') + if (!response.ok) { + throw new Error('Failed to fetch drops') + } + const data = await response.json() + // Ensure data is always an array + setDrops(Array.isArray(data) ? data : []) + } catch (error) { + console.error('Error fetching drops:', error) + setDrops([]) // Set to empty array on error + } finally { + setLoading(false) + } + } + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setFormData({ + ...formData, + imageFile: file, + imagePreview: URL.createObjectURL(file), + }) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + + try { + let imageUrl = '' + + // Upload image if provided + if (formData.imageFile) { + setUploadingImage(true) + const uploadFormData = new FormData() + uploadFormData.append('file', formData.imageFile) + + const uploadResponse = await fetch('/api/upload', { + method: 'POST', + body: uploadFormData, + }) + + if (!uploadResponse.ok) { + const error = await uploadResponse.json() + alert(`Image upload failed: ${error.error}`) + setUploadingImage(false) + setSubmitting(false) + return + } + + const uploadData = await uploadResponse.json() + imageUrl = uploadData.url + setUploadingImage(false) + } + + // Create drop + const response = await fetch('/api/drops', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + item: formData.item, + size: parseInt(formData.size), + unit: formData.unit.trim() || 'g', + ppu: parseFloat(formData.ppu), + imageUrl: imageUrl, + }), + }) + + if (response.ok) { + // Reset form + setFormData({ + item: '', + size: '', + unit: 'g', + ppu: '', + imageFile: null, + imagePreview: '', + }) + // Clear file input + const fileInput = document.getElementById('imageFile') as HTMLInputElement + if (fileInput) fileInput.value = '' + // Refresh drops list + fetchDrops() + alert('Drop created successfully!') + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error creating drop:', error) + alert('Failed to create drop') + } finally { + setSubmitting(false) + setUploadingImage(false) + } + } + + const getProgressPercentage = (fill: number, size: number) => { + return Math.min((fill / size) * 100, 100).toFixed(1) + } + + const isSoldOut = (fill: number, size: number) => { + return fill >= size + } + + return ( +
+
+
+

Admin Panel

+ +
+ +
+ {/* Create Drop Form */} +
+

Create New Drop

+
+
+ + + setFormData({ ...formData, item: e.target.value }) + } + required + placeholder="e.g. Harlequin – Collective Drop" + /> +
+ +
+
+ + + setFormData({ ...formData, size: e.target.value }) + } + required + placeholder="1000" + min="1" + /> +
+ +
+ + + setFormData({ ...formData, unit: e.target.value }) + } + placeholder="g, kg, ml, etc." + required + maxLength={12} + /> +
+
+ +
+ + + setFormData({ ...formData, ppu: e.target.value }) + } + required + placeholder="2.50" + step="0.01" + min="0" + /> +
+ +
+ + + {formData.imagePreview && ( +
+ Preview +
+ )} +

+ Max file size: 5MB. Allowed formats: JPEG, PNG, WebP +

+
+ + +
+
+ + {/* Drops List */} +
+

All Drops

+ {loading ? ( +

Loading...

+ ) : !Array.isArray(drops) || drops.length === 0 ? ( +

No drops yet

+ ) : ( +
+ {drops.map((drop) => ( +
+
+
+

{drop.item}

+

+ ID: {drop.id} · Created:{' '} + {new Date(drop.created_at).toLocaleDateString()} +

+
+ {isSoldOut(drop.fill, drop.size) && ( + Sold Out + )} +
+ +
+
+ + {drop.fill} + {drop.unit} / {drop.size} + {drop.unit} + + + {getProgressPercentage(drop.fill, drop.size)}% + +
+
+ +
+
+ +
+ {drop.ppu.toFixed(2)} CHF / {drop.unit} +
+
+ ))} +
+ )} +
+
+
+
+ ) +} + diff --git a/app/api/drops/active/route.ts b/app/api/drops/active/route.ts new file mode 100644 index 0000000..24f84d0 --- /dev/null +++ b/app/api/drops/active/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/drops/active - Get the currently active drop (not sold out) +export async function GET() { + try { + const [rows] = await pool.execute( + 'SELECT * FROM drops WHERE fill < size ORDER BY created_at DESC LIMIT 1' + ) + + const drops = rows as any[] + return NextResponse.json(drops[0] || null) + } catch (error) { + console.error('Error fetching active drop:', error) + return NextResponse.json( + { error: 'Failed to fetch active drop' }, + { status: 500 } + ) + } +} + diff --git a/app/api/drops/route.ts b/app/api/drops/route.ts new file mode 100644 index 0000000..e8ed42c --- /dev/null +++ b/app/api/drops/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/drops - Get all drops +export async function GET(request: NextRequest) { + try { + const [rows] = await pool.execute( + 'SELECT * FROM drops ORDER BY created_at DESC' + ) + return NextResponse.json(rows) + } catch (error) { + console.error('Error fetching drops:', error) + return NextResponse.json( + { error: 'Failed to fetch drops' }, + { status: 500 } + ) + } +} + +// POST /api/drops - Create a new drop +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { item, size, unit = 'g', ppu, imageUrl } = body + + // Validate required fields + if (!item || !size || !ppu) { + return NextResponse.json( + { error: 'Missing required fields: item, size, ppu' }, + { status: 400 } + ) + } + + // Insert new drop + // Note: If imageUrl column doesn't exist in database, add it with: + // ALTER TABLE drops ADD COLUMN image_url VARCHAR(255) DEFAULT NULL AFTER unit; + const [result] = await pool.execute( + 'INSERT INTO drops (item, size, unit, ppu, fill, image_url) VALUES (?, ?, ?, ?, 0, ?)', + [item, size, unit, ppu, imageUrl || null] + ) + + const insertId = (result as any).insertId + + // Fetch the created drop + const [rows] = await pool.execute('SELECT * FROM drops WHERE id = ?', [ + insertId, + ]) + + return NextResponse.json(rows[0], { status: 201 }) + } catch (error) { + console.error('Error creating drop:', error) + return NextResponse.json( + { error: 'Failed to create drop' }, + { status: 500 } + ) + } +} + diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..96a3b6d --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { existsSync } from 'fs' + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const file = formData.get('file') as File + + if (!file) { + return NextResponse.json( + { error: 'No file uploaded' }, + { status: 400 } + ) + } + + // Validate file type + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] + if (!validTypes.includes(file.type)) { + return NextResponse.json( + { error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed.' }, + { status: 400 } + ) + } + + // Validate file size (max 5MB) + const maxSize = 5 * 1024 * 1024 // 5MB + if (file.size > maxSize) { + return NextResponse.json( + { error: 'File size too large. Maximum size is 5MB.' }, + { status: 400 } + ) + } + + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + + // Create uploads directory if it doesn't exist + const uploadsDir = join(process.cwd(), 'public', 'uploads') + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }) + } + + // Generate unique filename + const timestamp = Date.now() + const originalName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_') + const filename = `${timestamp}-${originalName}` + const filepath = join(uploadsDir, filename) + + // Write file + await writeFile(filepath, buffer) + + // Return the public URL + const fileUrl = `/uploads/${filename}` + + return NextResponse.json({ url: fileUrl }, { status: 200 }) + } catch (error) { + console.error('Error uploading file:', error) + return NextResponse.json( + { error: 'Failed to upload file' }, + { status: 500 } + ) + } +} + diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx new file mode 100644 index 0000000..c32bcd4 --- /dev/null +++ b/app/components/Drop.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' + +export default function Drop() { + const [selectedSize, setSelectedSize] = useState('50g') + + return ( +
+ Harlequin CBD +
+

Harlequin – Collective Drop

+
1kg Batch · Indoor · Switzerland
+
2.50 CHF / g · incl. 2.5% VAT
+ +
+ +
+
620g of 1,000g reserved
+ +
+ + + +
+ + +
+
+ ) +} + diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx new file mode 100644 index 0000000..6c0ef1e --- /dev/null +++ b/app/components/Footer.tsx @@ -0,0 +1,8 @@ +export default function Footer() { + return ( +
+ © 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland +
+ ) +} + diff --git a/app/components/InfoBox.tsx b/app/components/InfoBox.tsx new file mode 100644 index 0000000..31bd6e1 --- /dev/null +++ b/app/components/InfoBox.tsx @@ -0,0 +1,28 @@ +export default function InfoBox() { + return ( +
+
+

Why so cheap?

+

+ Retail prices are around 10 CHF/g. Through collective + bulk orders, we buy like wholesalers – without + intermediaries. +

+
+
+

Taxes & Legal

+

+ Bulk sale with 2.5% VAT. No retail packaging, no + tobacco tax. +

+
+
+

Drop Model

+

+ One variety per drop. Only when sold out – then the next drop. +

+
+
+ ) +} + diff --git a/app/components/Nav.tsx b/app/components/Nav.tsx new file mode 100644 index 0000000..ab30887 --- /dev/null +++ b/app/components/Nav.tsx @@ -0,0 +1,13 @@ +export default function Nav() { + return ( + + ) +} + diff --git a/app/components/PastDrops.tsx b/app/components/PastDrops.tsx new file mode 100644 index 0000000..62d1541 --- /dev/null +++ b/app/components/PastDrops.tsx @@ -0,0 +1,47 @@ +import Image from 'next/image' + +interface PastDrop { + name: string + image: string + soldIn: string +} + +const pastDrops: PastDrop[] = [ + { + name: 'Swiss Gold', + image: 'https://images.unsplash.com/photo-1581091012184-5c7b4c101899', + soldIn: 'Sold out in 42h', + }, + { + name: 'Lemon T1', + image: 'https://images.unsplash.com/photo-1512436991641-6745cdb1723f', + soldIn: 'Sold out in 19h', + }, + { + name: 'Alpine Frost', + image: 'https://images.unsplash.com/photo-1600431521340-491eca880813', + soldIn: 'Sold out in 31h', + }, +] + +export default function PastDrops() { + return ( +
+ {pastDrops.map((drop, index) => ( +
+ {drop.name} + {drop.name} +
+ {drop.soldIn} +
+ ))} +
+ ) +} + diff --git a/app/components/Signup.tsx b/app/components/Signup.tsx new file mode 100644 index 0000000..8dee68f --- /dev/null +++ b/app/components/Signup.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useState } from 'react' + +export default function Signup() { + const [email, setEmail] = useState('') + const [whatsapp, setWhatsapp] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + // Handle form submission + console.log('Form submitted', { email, whatsapp }) + } + + return ( +
+

Drop Notifications

+

Receive updates about new drops via email or WhatsApp.

+
+ setEmail(e.target.value)} + /> + setWhatsapp(e.target.value)} + /> +
+ +
+
+ ) +} + diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..de81a31 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,436 @@ +:root { + --bg: #0e0e0e; + --bg-soft: #151515; + --card: #1c1c1c; + --text: #eaeaea; + --muted: #9a9a9a; + --accent: #3ddc84; + --border: #2a2a2a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Inter', sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +a { + color: inherit; + text-decoration: none; +} + +nav { + position: sticky; + top: 0; + z-index: 10; + background: rgba(14, 14, 14, 0.9); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + padding: 20px 40px; + display: flex; + justify-content: space-between; + align-items: center; +} + +nav .brand { + font-weight: 600; + font-size: 18px; + letter-spacing: 0.5px; +} + +nav .links a { + margin-left: 28px; + font-size: 14px; + color: var(--muted); +} + +nav .links a:hover { + color: var(--text); +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 80px 20px; +} + +header { + padding-top: 120px; + padding-bottom: 80px; +} + +header h1 { + font-size: 44px; + font-weight: 600; + max-width: 760px; + letter-spacing: -0.5px; +} + +header p { + margin-top: 20px; + font-size: 18px; + color: var(--muted); + max-width: 620px; +} + +.drop { + background: var(--card); + border-radius: 20px; + padding: 40px; + display: grid; + grid-template-columns: 420px 1fr; + gap: 50px; + border: 1px solid var(--border); +} + +.drop img { + width: 100%; + border-radius: 16px; + object-fit: cover; +} + +.drop h2 { + font-size: 28px; + margin: 0 0 10px; +} + +.drop .meta { + color: var(--muted); + margin-bottom: 20px; +} + +.price { + font-size: 22px; + font-weight: 500; + margin-bottom: 20px; +} + +.progress { + background: var(--bg-soft); + border-radius: 10px; + height: 10px; + overflow: hidden; + margin-bottom: 20px; +} + +.progress span { + display: block; + height: 100%; + width: 62%; + background: linear-gradient(90deg, var(--accent), #1fa463); +} + +.options button { + background: transparent; + border: 1px solid var(--border); + color: var(--text); + padding: 14px 20px; + margin-right: 12px; + border-radius: 12px; + cursor: pointer; + font-size: 14px; +} + +.options button.active, +.options button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.cta { + margin-top: 30px; + padding: 16px 28px; + background: var(--accent); + color: #000; + border: none; + border-radius: 14px; + font-size: 15px; + font-weight: 500; + cursor: pointer; +} + +.info-box { + margin-top: 60px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 30px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 30px; +} + +.info-box div h3 { + margin-bottom: 8px; + font-size: 18px; +} + +.info-box div p { + color: var(--muted); + font-size: 14px; +} + +.signup { + background: var(--card); + border-radius: 20px; + padding: 50px; + text-align: center; + border: 1px solid var(--border); +} + +.signup input { + width: 260px; + padding: 14px; + margin: 10px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-soft); + color: var(--text); +} + +.signup button { + margin-top: 20px; + padding: 14px 28px; + background: var(--accent); + color: #000; + border: none; + border-radius: 14px; + cursor: pointer; +} + +.past { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 30px; +} + +.past .card { + background: var(--card); + border-radius: 16px; + padding: 20px; + border: 1px solid var(--border); +} + +.past img { + width: 100%; + border-radius: 12px; + margin-bottom: 12px; +} + +footer { + padding: 60px 20px; + text-align: center; + color: var(--muted); + font-size: 13px; + border-top: 1px solid var(--border); +} + +@media (max-width: 900px) { + .drop { + grid-template-columns: 1fr; + } +} + +/* Admin Panel Styles */ +.admin-page { + min-height: 100vh; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} + +.admin-header button { + padding: 12px 24px; + background: transparent; + border: 1px solid var(--border); + color: var(--text); + border-radius: 12px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.admin-header button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.admin-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; +} + +.admin-section { + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 40px; +} + +.admin-section h2 { + margin-top: 0; + margin-bottom: 30px; + font-size: 24px; +} + +.admin-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-size: 14px; + color: var(--muted); + font-weight: 500; +} + +.form-group input, +.form-group select { + padding: 14px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-soft); + color: var(--text); + font-size: 14px; + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); +} + +.form-group .file-input { + padding: 10px; + cursor: pointer; +} + +.form-group .file-input::file-selector-button { + padding: 8px 16px; + margin-right: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + color: var(--text); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.form-group .file-input::file-selector-button:hover { + border-color: var(--accent); + background: var(--bg-soft); +} + +.image-preview { + margin-top: 12px; + border-radius: 12px; + overflow: hidden; + max-width: 200px; + border: 1px solid var(--border); +} + +.image-preview img { + width: 100%; + height: auto; + display: block; +} + +.file-hint { + margin-top: 8px; + font-size: 12px; + color: var(--muted); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 80px; + gap: 12px; +} + +.drops-list { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 600px; + overflow-y: auto; +} + +.drop-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.drop-card-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 12px; +} + +.drop-card-info h3 { + margin: 0 0 8px 0; + font-size: 18px; +} + +.drop-card-info p { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.sold-out-badge { + padding: 4px 12px; + background: var(--accent); + color: #000; + border-radius: 6px; + font-size: 12px; + font-weight: 500; +} + +.drop-card-progress { + margin-bottom: 12px; +} + +.drop-card-progress-header { + display: flex; + justify-content: space-between; + font-size: 14px; + margin-bottom: 8px; +} + +.drop-card-progress-header span:last-child { + font-weight: 500; +} + +.drop-card-price { + font-size: 14px; + color: var(--muted); +} + +@media (max-width: 1000px) { + .admin-grid { + grid-template-columns: 1fr; + } +} + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..012f443 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'], weight: ['300', '400', '500', '600'] }) + +export const metadata: Metadata = { + title: '420Deals.ch – Premium Swiss CBD Drops', + description: 'Shop together. Wholesale prices for private buyers.', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..e305db9 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,38 @@ +import Nav from './components/Nav' +import Drop from './components/Drop' +import InfoBox from './components/InfoBox' +import Signup from './components/Signup' +import PastDrops from './components/PastDrops' +import Footer from './components/Footer' + +export default function Home() { + return ( + <> +