This commit is contained in:
2025-12-20 10:32:36 +05:30
commit 91c68831bf
23 changed files with 2414 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -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

67
README.md Normal file
View File

@@ -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

324
app/admin/page.tsx Normal file
View File

@@ -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<Drop[]>([])
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<HTMLInputElement>) => {
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 (
<div className="admin-page">
<div className="container">
<div className="admin-header">
<h1>Admin Panel</h1>
<button onClick={() => router.push('/')}>View Site</button>
</div>
<div className="admin-grid">
{/* Create Drop Form */}
<div className="admin-section">
<h2>Create New Drop</h2>
<form onSubmit={handleSubmit} className="admin-form">
<div className="form-group">
<label htmlFor="item">Product Name</label>
<input
type="text"
id="item"
value={formData.item}
onChange={(e) =>
setFormData({ ...formData, item: e.target.value })
}
required
placeholder="e.g. Harlequin Collective Drop"
/>
</div>
<div className="form-grid">
<div className="form-group">
<label htmlFor="size">Batch Size</label>
<input
type="number"
id="size"
value={formData.size}
onChange={(e) =>
setFormData({ ...formData, size: e.target.value })
}
required
placeholder="1000"
min="1"
/>
</div>
<div className="form-group">
<label htmlFor="unit">Unit</label>
<input
type="text"
id="unit"
value={formData.unit}
onChange={(e) =>
setFormData({ ...formData, unit: e.target.value })
}
placeholder="g, kg, ml, etc."
required
maxLength={12}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="ppu">Price Per Gram (CHF)</label>
<input
type="number"
id="ppu"
value={formData.ppu}
onChange={(e) =>
setFormData({ ...formData, ppu: e.target.value })
}
required
placeholder="2.50"
step="0.01"
min="0"
/>
</div>
<div className="form-group">
<label htmlFor="imageFile">Product Image (optional)</label>
<input
type="file"
id="imageFile"
accept="image/jpeg,image/jpg,image/png,image/webp"
onChange={handleImageChange}
className="file-input"
/>
{formData.imagePreview && (
<div className="image-preview">
<img src={formData.imagePreview} alt="Preview" />
</div>
)}
<p className="file-hint">
Max file size: 5MB. Allowed formats: JPEG, PNG, WebP
</p>
</div>
<button
type="submit"
disabled={submitting || uploadingImage}
className="cta"
style={{ width: '100%', marginTop: '20px' }}
>
{uploadingImage
? 'Uploading image...'
: submitting
? 'Creating...'
: 'Create Drop'}
</button>
</form>
</div>
{/* Drops List */}
<div className="admin-section">
<h2>All Drops</h2>
{loading ? (
<p style={{ color: 'var(--muted)' }}>Loading...</p>
) : !Array.isArray(drops) || drops.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No drops yet</p>
) : (
<div className="drops-list">
{drops.map((drop) => (
<div
key={drop.id}
className="drop-card"
style={{
background: isSoldOut(drop.fill, drop.size)
? 'var(--bg-soft)'
: 'var(--card)',
opacity: isSoldOut(drop.fill, drop.size) ? 0.7 : 1,
}}
>
<div className="drop-card-header">
<div className="drop-card-info">
<h3>{drop.item}</h3>
<p>
ID: {drop.id} · Created:{' '}
{new Date(drop.created_at).toLocaleDateString()}
</p>
</div>
{isSoldOut(drop.fill, drop.size) && (
<span className="sold-out-badge">Sold Out</span>
)}
</div>
<div className="drop-card-progress">
<div className="drop-card-progress-header">
<span style={{ color: 'var(--muted)' }}>
{drop.fill}
{drop.unit} / {drop.size}
{drop.unit}
</span>
<span>
{getProgressPercentage(drop.fill, drop.size)}%
</span>
</div>
<div className="progress">
<span
style={{
width: `${getProgressPercentage(
drop.fill,
drop.size
)}%`,
}}
/>
</div>
</div>
<div className="drop-card-price">
{drop.ppu.toFixed(2)} CHF / {drop.unit}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 }
)
}
}

58
app/api/drops/route.ts Normal file
View File

@@ -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 }
)
}
}

66
app/api/upload/route.ts Normal file
View File

@@ -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 }
)
}
}

54
app/components/Drop.tsx Normal file
View File

@@ -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 (
<div className="drop">
<Image
src="https://images.unsplash.com/photo-1604908554027-0b6c2c9c7e92"
alt="Harlequin CBD"
width={420}
height={420}
style={{ width: '100%', height: 'auto', borderRadius: '16px', objectFit: 'cover' }}
/>
<div>
<h2>Harlequin Collective Drop</h2>
<div className="meta">1kg Batch · Indoor · Switzerland</div>
<div className="price">2.50 CHF / g · incl. 2.5% VAT</div>
<div className="progress">
<span></span>
</div>
<div className="meta">620g of 1,000g reserved</div>
<div className="options">
<button
className={selectedSize === '50g' ? 'active' : ''}
onClick={() => setSelectedSize('50g')}
>
50g
</button>
<button
className={selectedSize === '100g' ? 'active' : ''}
onClick={() => setSelectedSize('100g')}
>
100g
</button>
<button
className={selectedSize === '250g' ? 'active' : ''}
onClick={() => setSelectedSize('250g')}
>
250g
</button>
</div>
<button className="cta">Join Drop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function Footer() {
return (
<footer>
© 2025 420Deals.ch · CBD &lt; 1% THC · Sale from 18 years · Switzerland
</footer>
)
}

View File

@@ -0,0 +1,28 @@
export default function InfoBox() {
return (
<div className="info-box">
<div>
<h3>Why so cheap?</h3>
<p>
Retail prices are around 10 CHF/g. Through collective
bulk orders, we buy like wholesalers without
intermediaries.
</p>
</div>
<div>
<h3>Taxes & Legal</h3>
<p>
Bulk sale with 2.5% VAT. No retail packaging, no
tobacco tax.
</p>
</div>
<div>
<h3>Drop Model</h3>
<p>
One variety per drop. Only when sold out then the next drop.
</p>
</div>
</div>
)
}

13
app/components/Nav.tsx Normal file
View File

@@ -0,0 +1,13 @@
export default function Nav() {
return (
<nav>
<div className="brand">420Deals.ch</div>
<div className="links">
<a href="#drop">Drop</a>
<a href="#past">Past Drops</a>
<a href="#community">Community</a>
</div>
</nav>
)
}

View File

@@ -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 (
<div className="past">
{pastDrops.map((drop, index) => (
<div key={index} className="card">
<Image
src={drop.image}
alt={drop.name}
width={240}
height={240}
style={{ width: '100%', height: 'auto', borderRadius: '12px', marginBottom: '12px' }}
/>
<strong>{drop.name}</strong>
<br />
<span className="meta">{drop.soldIn}</span>
</div>
))}
</div>
)
}

38
app/components/Signup.tsx Normal file
View File

@@ -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 (
<div className="signup">
<h2>Drop Notifications</h2>
<p>Receive updates about new drops via email or WhatsApp.</p>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="text"
placeholder="WhatsApp Number"
value={whatsapp}
onChange={(e) => setWhatsapp(e.target.value)}
/>
<br />
<button type="submit">Get Notified</button>
</form>
</div>
)
}

436
app/globals.css Normal file
View File

@@ -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;
}
}

23
app/layout.tsx Normal file
View File

@@ -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 (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

38
app/page.tsx Normal file
View File

@@ -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 (
<>
<Nav />
<header className="container">
<h1>Shop together. Wholesale prices for private buyers.</h1>
<p>
Limited CBD drops directly from Swiss producers. No retail.
No markup. Just collective bulk prices.
</p>
</header>
<section className="container" id="drop">
<Drop />
<InfoBox />
</section>
<section className="container" id="community">
<Signup />
</section>
<section className="container" id="past">
<h2>Past Drops</h2>
<PastDrops />
</section>
<Footer />
</>
)
}

159
cbd420.sql Normal file
View File

@@ -0,0 +1,159 @@
-- phpMyAdmin SQL Dump
-- version 5.2.1deb1+deb12u1
-- https://www.phpmyadmin.net/
--
-- Host: localhost:3306
-- Generation Time: Dec 20, 2025 at 04:42 AM
-- Server version: 10.11.14-MariaDB-0+deb12u2
-- PHP Version: 8.2.29
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `cbd420`
--
-- --------------------------------------------------------
--
-- Table structure for table `buyers`
--
CREATE TABLE `buyers` (
`id` int(11) NOT NULL,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `deliveries`
--
CREATE TABLE `deliveries` (
`id` int(11) NOT NULL,
`sale_id` int(11) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`status` text NOT NULL DEFAULT 'Pending'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `drops`
--
CREATE TABLE `drops` (
`id` int(11) NOT NULL,
`item` text NOT NULL,
`size` int(11) NOT NULL DEFAULT 100,
`fill` int(11) NOT NULL DEFAULT 0,
`unit` varchar(12) NOT NULL DEFAULT 'g',
`ppu` int(11) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `sales`
--
CREATE TABLE `sales` (
`id` int(11) NOT NULL,
`drop_id` int(11) NOT NULL,
`buyer_id` int(11) NOT NULL,
`size` int(11) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Indexes for dumped tables
--
--
-- Indexes for table `buyers`
--
ALTER TABLE `buyers`
ADD PRIMARY KEY (`id`);
--
-- Indexes for table `deliveries`
--
ALTER TABLE `deliveries`
ADD PRIMARY KEY (`id`),
ADD KEY `sale_id` (`sale_id`);
--
-- Indexes for table `drops`
--
ALTER TABLE `drops`
ADD PRIMARY KEY (`id`);
--
-- Indexes for table `sales`
--
ALTER TABLE `sales`
ADD PRIMARY KEY (`id`),
ADD KEY `drop_id` (`drop_id`),
ADD KEY `buyer_id` (`buyer_id`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `buyers`
--
ALTER TABLE `buyers`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `deliveries`
--
ALTER TABLE `deliveries`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `drops`
--
ALTER TABLE `drops`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `sales`
--
ALTER TABLE `sales`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- Constraints for dumped tables
--
--
-- Constraints for table `deliveries`
--
ALTER TABLE `deliveries`
ADD CONSTRAINT `deliveries_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
--
-- Constraints for table `sales`
--
ALTER TABLE `sales`
ADD CONSTRAINT `sales_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT `sales_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

287
index.html Normal file
View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>420Deals.ch Premium Swiss CBD Drops</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
: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; }
}
</style>
</head>
<body>
<nav>
<div class="brand">420Deals.ch</div>
<div class="links">
<a href="#drop">Drop</a>
<a href="#past">Vergangene Drops</a>
<a href="#community">Community</a>
</div>
</nav>
<header class="container">
<h1>Gemeinsam einkaufen. Wholesale-Preise für private Käufer.</h1>
<p>Limitierte CBD Drops direkt von Schweizer Produzenten. Kein Retail. Kein Marketing-Aufschlag. Nur kollektive Mengenpreise.</p>
</header>
<section class="container" id="drop">
<div class="drop">
<img src="https://images.unsplash.com/photo-1604908554027-0b6c2c9c7e92" />
<div>
<h2>Harlequin Collective Drop</h2>
<div class="meta">1kg Batch · Indoor · Schweiz</div>
<div class="price">2.50 CHF / g · inkl. 2.5% MWST</div>
<div class="progress"><span></span></div>
<div class="meta">620g von 1'000g reserviert</div>
<div class="options">
<button class="active">50g</button>
<button>100g</button>
<button>250g</button>
</div>
<button class="cta">Am Drop teilnehmen</button>
</div>
</div>
<div class="info-box">
<div>
<h3>Warum so günstig?</h3>
<p>Retailpreise liegen bei ca. 10 CHF/g. Durch kollektive Sammelbestellungen kaufen wir wie Grosshändler ein ohne Zwischenstufen.</p>
</div>
<div>
<h3>Steuern & Recht</h3>
<p>Bulk-Verkauf mit 2.5% MWST. Keine Retail-Verpackung, keine Tabaksteuer.</p>
</div>
<div>
<h3>Drop-Modell</h3>
<p>Pro Drop nur eine Sorte. Erst ausverkauft dann der nächste Drop.</p>
</div>
</div>
</section>
<section class="container" id="community">
<div class="signup">
<h2>Drop-Benachrichtigungen</h2>
<p>Erhalte Updates zu neuen Drops per E-Mail oder WhatsApp.</p>
<input type="email" placeholder="E-Mail" />
<input type="text" placeholder="WhatsApp Nummer" />
<br />
<button>Benachrichtigen lassen</button>
</div>
</section>
<section class="container" id="past">
<h2>Vergangene Drops</h2>
<div class="past">
<div class="card">
<img src="https://images.unsplash.com/photo-1581091012184-5c7b4c101899" />
<strong>Swiss Gold</strong><br><span class="meta">Ausverkauft in 42h</span>
</div>
<div class="card">
<img src="https://images.unsplash.com/photo-1512436991641-6745cdb1723f" />
<strong>Lemon T1</strong><br><span class="meta">Ausverkauft in 19h</span>
</div>
<div class="card">
<img src="https://images.unsplash.com/photo-1600431521340-491eca880813" />
<strong>Alpine Frost</strong><br><span class="meta">Ausverkauft in 31h</span>
</div>
</div>
</section>
<footer>
© 2025 420Deals.ch · CBD < 1% THC · Verkauf ab 18 Jahren · Schweiz
</footer>
</body>
</html>

15
lib/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import mysql from 'mysql2/promise'
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'cbd420',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
export default pool

View File

@@ -0,0 +1,6 @@
-- Migration: Add image_url column to drops table
-- Run this to add support for image uploads
ALTER TABLE `drops`
ADD COLUMN `image_url` VARCHAR(255) DEFAULT NULL AFTER `unit`;

15
next.config.js Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
}
module.exports = nextConfig

621
package-lock.json generated Normal file
View File

@@ -0,0 +1,621 @@
{
"name": "420deals-ch",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "420deals-ch",
"version": "0.1.0",
"dependencies": {
"mysql2": "^3.16.0",
"next": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
},
"node_modules/@next/env": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001761",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/mysql2": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz",
"integrity": "sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "420deals-ch",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"mysql2": "^3.16.0",
"next": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}