before race
This commit is contained in:
146
Requirements.md
Normal file
146
Requirements.md
Normal file
@@ -0,0 +1,146 @@
|
||||
420Deals.ch – Project Brief (for Dev)
|
||||
|
||||
Core idea:
|
||||
420Deals.ch is NOT a normal shop.
|
||||
It’s a collective drop system.
|
||||
|
||||
There is always only ONE active product drop.
|
||||
Each drop has a fixed total batch (e.g. 1kg).
|
||||
Users buy parts of the batch (50g / 100g / 250g).
|
||||
Everyone pays the same wholesale price per gram.
|
||||
When the batch is fully sold → the drop ends automatically.
|
||||
Only after that, the next drop goes live.
|
||||
|
||||
Value proposition:
|
||||
Wholesale prices for private buyers through collective purchasing.
|
||||
No retail packaging. No marketing markup.
|
||||
|
||||
Page structure:
|
||||
Everything happens on ONE page.
|
||||
|
||||
Navigation (sticky):
|
||||
|
||||
Drop
|
||||
|
||||
Past Drops
|
||||
|
||||
Community
|
||||
|
||||
Header:
|
||||
Text only.
|
||||
Explains why prices are low.
|
||||
No big images, no CTA.
|
||||
User attention goes directly to the active drop.
|
||||
|
||||
Current Drop section:
|
||||
|
||||
Product image
|
||||
|
||||
Product name + batch info (e.g. 1kg, indoor, Switzerland)
|
||||
|
||||
Price per gram (incl. 2.5% VAT)
|
||||
|
||||
Live progress bar (how much of the batch is sold)
|
||||
|
||||
Quantity selection (50g / 100g / 250g)
|
||||
|
||||
CTA: “Join the Drop”
|
||||
|
||||
Important:
|
||||
Same price for everyone.
|
||||
No discounts, no codes.
|
||||
The advantage comes only from collective volume.
|
||||
|
||||
Progress bar logic:
|
||||
Example:
|
||||
Total batch: 1000g
|
||||
Sold: 620g
|
||||
Progress: 62%
|
||||
|
||||
Purpose:
|
||||
Transparency, trust, FOMO.
|
||||
|
||||
Auto-Switch Drop (important):
|
||||
When soldGrams >= totalBatch:
|
||||
|
||||
Replace the entire drop section with:
|
||||
“Drop sold out”
|
||||
“Next collective drop coming soon”
|
||||
|
||||
Countdown timer
|
||||
|
||||
Users cannot buy anymore, only wait or subscribe.
|
||||
|
||||
Countdown:
|
||||
Countdown shows days / hours / minutes to next drop.
|
||||
When countdown ends → next drop can go live.
|
||||
|
||||
Why it’s cheap section:
|
||||
Short explanation:
|
||||
Retail ~10 CHF/g
|
||||
Collective ~2.5 CHF/g
|
||||
No retail packaging
|
||||
No tobacco tax
|
||||
No intermediaries
|
||||
|
||||
Tone: factual, clean, Swiss-style.
|
||||
|
||||
Community / Notifications:
|
||||
Purpose: users don’t need to check the site daily.
|
||||
|
||||
Fields:
|
||||
|
||||
Email
|
||||
|
||||
WhatsApp number
|
||||
|
||||
Optional later:
|
||||
Telegram broadcast (one-way only, no public chat).
|
||||
|
||||
No community chat on site → keeps it premium and controlled.
|
||||
|
||||
Past Drops:
|
||||
Show previous drops:
|
||||
|
||||
Product name
|
||||
|
||||
“Sold out in XX hours”
|
||||
|
||||
Purpose:
|
||||
Trust, credibility, FOMO.
|
||||
|
||||
Position:
|
||||
After current drop, before footer.
|
||||
|
||||
Footer:
|
||||
|
||||
THC < 1%
|
||||
|
||||
18+ only
|
||||
|
||||
Switzerland
|
||||
|
||||
Minimal text
|
||||
|
||||
Technical notes:
|
||||
Frontend:
|
||||
Dark theme, no emojis, no flashy colors.
|
||||
Focus on typography and spacing.
|
||||
|
||||
Backend:
|
||||
Drop object:
|
||||
|
||||
Name
|
||||
|
||||
Total batch size
|
||||
|
||||
Sold amount
|
||||
|
||||
Start / end date
|
||||
Progress bar updates dynamically.
|
||||
Auto-switch when sold out.
|
||||
Countdown configurable.
|
||||
|
||||
Summary:
|
||||
420Deals.ch is not a shop, not a forum, not a headshop.
|
||||
It’s a premium collective buying platform for CBD in Switzerland.
|
||||
@@ -419,8 +419,8 @@ export default function Drop() {
|
||||
disabled={processing}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'var(--accent)',
|
||||
color: '#000',
|
||||
background: '#0a7931',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
cursor: processing ? 'not-allowed' : 'pointer',
|
||||
|
||||
@@ -54,7 +54,11 @@ export default function Nav() {
|
||||
return (
|
||||
<>
|
||||
<nav>
|
||||
<div className="brand">420Deals.ch</div>
|
||||
<div className="brand">
|
||||
<a href="/" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||
<img src="/header.jpg" alt="420Deals.ch" style={{ height: '40px', width: 'auto' }} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="links">
|
||||
<a href="#drop">Drop</a>
|
||||
<a href="#past">Past Drops</a>
|
||||
@@ -90,8 +94,8 @@ export default function Nav() {
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
background: 'var(--accent)',
|
||||
color: '#000',
|
||||
background: '#0a7931',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -15,7 +15,12 @@ interface PastDrop {
|
||||
soldOutInHours: number
|
||||
}
|
||||
|
||||
export default function PastDrops() {
|
||||
interface PastDropsProps {
|
||||
limit?: number
|
||||
showMoreLink?: boolean
|
||||
}
|
||||
|
||||
export default function PastDrops({ limit, showMoreLink = false }: PastDropsProps = {}) {
|
||||
const [drops, setDrops] = useState<PastDrop[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -55,6 +60,20 @@ export default function PastDrops() {
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateAndTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const day = date.getDate()
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
const month = monthNames[date.getMonth()]
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${day} ${month} · ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const formatQuantity = (size: number, unit: string) => {
|
||||
return `${size}${unit}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="past">
|
||||
@@ -73,50 +92,72 @@ export default function PastDrops() {
|
||||
)
|
||||
}
|
||||
|
||||
const displayedDrops = limit ? drops.slice(0, limit) : drops
|
||||
const hasMore = limit && drops.length > limit
|
||||
|
||||
return (
|
||||
<div className="past">
|
||||
{drops.map((drop) => (
|
||||
<div key={drop.id} className="card">
|
||||
{drop.image_url ? (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Image
|
||||
src={drop.image_url}
|
||||
alt={drop.item}
|
||||
width={300}
|
||||
height={300}
|
||||
<>
|
||||
<div className="past">
|
||||
{displayedDrops.map((drop) => (
|
||||
<div key={drop.id} className="card">
|
||||
{drop.image_url ? (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Image
|
||||
src={drop.image_url}
|
||||
alt={drop.item}
|
||||
width={300}
|
||||
height={300}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
height: '300px',
|
||||
height: '200px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'cover',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
height: '300px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<strong>{drop.item}</strong>
|
||||
<br />
|
||||
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
|
||||
>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<strong>{drop.item}</strong>
|
||||
<br />
|
||||
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
|
||||
<br />
|
||||
<span className="meta" style={{ fontSize: '13px', marginTop: '4px', display: 'block' }}>
|
||||
{formatQuantity(drop.size, drop.unit)} · {formatDateAndTime(drop.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showMoreLink && hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: '30px' }}>
|
||||
<a
|
||||
href="/past-drops"
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
More →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -146,8 +146,8 @@ header p {
|
||||
.cta {
|
||||
margin-top: 30px;
|
||||
padding: 16px 28px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
background: #0a7931;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
@@ -197,8 +197,8 @@ header p {
|
||||
.signup button {
|
||||
margin-top: 20px;
|
||||
padding: 14px 28px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
background: #0a7931;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
@@ -206,9 +206,9 @@ header p {
|
||||
|
||||
.past {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 400px));
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.past .card {
|
||||
@@ -216,7 +216,7 @@ header p {
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.past img {
|
||||
@@ -237,6 +237,16 @@ footer {
|
||||
.drop {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.past {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.past {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin Panel Styles */
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Home() {
|
||||
|
||||
<section className="container" id="past">
|
||||
<h2>Past Drops</h2>
|
||||
<PastDrops />
|
||||
<PastDrops limit={3} showMoreLink={true} />
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
|
||||
39
app/past-drops/page.tsx
Normal file
39
app/past-drops/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import Nav from '../components/Nav'
|
||||
import PastDrops from '../components/PastDrops'
|
||||
import Footer from '../components/Footer'
|
||||
|
||||
export default function PastDropsPage() {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<section className="container" style={{ paddingTop: '120px' }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
marginBottom: '30px',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--muted)'
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
<h1 style={{ marginBottom: '40px' }}>Past Drops</h1>
|
||||
<PastDrops />
|
||||
</section>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
49
cbd420.sql
49
cbd420.sql
@@ -3,7 +3,7 @@
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- Host: localhost:3306
|
||||
-- Generation Time: Dec 20, 2025 at 04:42 AM
|
||||
-- Generation Time: Dec 21, 2025 at 06:50 AM
|
||||
-- Server version: 10.11.14-MariaDB-0+deb12u2
|
||||
-- PHP Version: 8.2.29
|
||||
|
||||
@@ -31,7 +31,8 @@ CREATE TABLE `buyers` (
|
||||
`id` int(11) NOT NULL,
|
||||
`username` varchar(255) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`email` varchar(255) NOT NULL
|
||||
`email` varchar(255) NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
@@ -59,7 +60,27 @@ CREATE TABLE `drops` (
|
||||
`size` int(11) NOT NULL DEFAULT 100,
|
||||
`fill` int(11) NOT NULL DEFAULT 0,
|
||||
`unit` varchar(12) NOT NULL DEFAULT 'g',
|
||||
`image_url` varchar(255) DEFAULT NULL,
|
||||
`ppu` int(11) NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`start_time` datetime DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `pending_orders`
|
||||
--
|
||||
|
||||
CREATE TABLE `pending_orders` (
|
||||
`id` int(11) NOT NULL,
|
||||
`payment_id` varchar(255) NOT NULL,
|
||||
`order_id` varchar(255) NOT NULL,
|
||||
`drop_id` int(11) NOT NULL,
|
||||
`buyer_id` int(11) NOT NULL,
|
||||
`size` int(11) NOT NULL,
|
||||
`price_amount` decimal(10,2) NOT NULL,
|
||||
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
@@ -74,6 +95,7 @@ CREATE TABLE `sales` (
|
||||
`drop_id` int(11) NOT NULL,
|
||||
`buyer_id` int(11) NOT NULL,
|
||||
`size` int(11) NOT NULL DEFAULT 1,
|
||||
`payment_id` text NOT NULL DEFAULT '',
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
@@ -100,6 +122,16 @@ ALTER TABLE `deliveries`
|
||||
ALTER TABLE `drops`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `pending_orders`
|
||||
--
|
||||
ALTER TABLE `pending_orders`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `payment_id` (`payment_id`),
|
||||
ADD UNIQUE KEY `order_id` (`order_id`),
|
||||
ADD KEY `drop_id` (`drop_id`),
|
||||
ADD KEY `buyer_id` (`buyer_id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `sales`
|
||||
--
|
||||
@@ -130,6 +162,12 @@ ALTER TABLE `deliveries`
|
||||
ALTER TABLE `drops`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `pending_orders`
|
||||
--
|
||||
ALTER TABLE `pending_orders`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `sales`
|
||||
--
|
||||
@@ -146,6 +184,13 @@ ALTER TABLE `sales`
|
||||
ALTER TABLE `deliveries`
|
||||
ADD CONSTRAINT `deliveries_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `pending_orders`
|
||||
--
|
||||
ALTER TABLE `pending_orders`
|
||||
ADD CONSTRAINT `pending_orders_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT `pending_orders_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `sales`
|
||||
--
|
||||
|
||||
BIN
public/header.jpg
Normal file
BIN
public/header.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Reference in New Issue
Block a user