init
This commit is contained in:
324
app/admin/page.tsx
Normal file
324
app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
21
app/api/drops/active/route.ts
Normal file
21
app/api/drops/active/route.ts
Normal 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
58
app/api/drops/route.ts
Normal 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
66
app/api/upload/route.ts
Normal 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
54
app/components/Drop.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
8
app/components/Footer.tsx
Normal file
8
app/components/Footer.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
28
app/components/InfoBox.tsx
Normal file
28
app/components/InfoBox.tsx
Normal 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
13
app/components/Nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
47
app/components/PastDrops.tsx
Normal file
47
app/components/PastDrops.tsx
Normal 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
38
app/components/Signup.tsx
Normal 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
436
app/globals.css
Normal 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
23
app/layout.tsx
Normal 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
38
app/page.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user