diff --git a/app/admin/drops/page.tsx b/app/admin/drops/page.tsx index 3bd1cc5..baa5d06 100644 --- a/app/admin/drops/page.tsx +++ b/app/admin/drops/page.tsx @@ -11,6 +11,7 @@ interface Drop { unit: string ppu: number image_url: string | null + images?: string[] created_at: string start_time: string | null } @@ -47,8 +48,9 @@ export default function DropsManagementPage() { imageUrl: '', startTime: '', }) - const [imageFile, setImageFile] = useState(null) - const [imagePreview, setImagePreview] = useState('') + const [imageFiles, setImageFiles] = useState([]) + const [imagePreviews, setImagePreviews] = useState([]) + const [existingImages, setExistingImages] = useState([]) const [uploadingImage, setUploadingImage] = useState(false) useEffect(() => { @@ -82,7 +84,7 @@ export default function DropsManagementPage() { } } - const handleEdit = (drop: Drop) => { + const handleEdit = async (drop: Drop) => { setEditingDrop(drop) setFormData({ item: drop.item, @@ -92,12 +94,64 @@ export default function DropsManagementPage() { imageUrl: drop.image_url || '', startTime: drop.start_time ? new Date(drop.start_time).toISOString().slice(0, 16) : '', }) + + // Fetch existing images for this drop + try { + const response = await fetch(`/api/drops/images?drop_id=${drop.id}`) + if (response.ok) { + const data = await response.json() + const imageUrls = data.map((img: any) => img.image_url) + setExistingImages(imageUrls) + } else { + setExistingImages(drop.image_url ? [drop.image_url] : []) + } + } catch (error) { + console.error('Error fetching drop images:', error) + setExistingImages(drop.image_url ? [drop.image_url] : []) + } + + // Clear file selections + setImageFiles([]) + setImagePreviews([]) } const handleSave = async () => { if (!editingDrop) return try { + setUploadingImage(true) + const imageUrls: string[] = [...existingImages] + + // Upload new image files + for (const file of imageFiles) { + const uploadFormData = new FormData() + uploadFormData.append('file', file) + + const uploadResponse = await fetch('/api/upload', { + method: 'POST', + body: uploadFormData, + }) + + if (uploadResponse.ok) { + const uploadData = await uploadResponse.json() + imageUrls.push(uploadData.url) + } else { + const error = await uploadResponse.json() + alert(`Image upload failed: ${error.error}`) + setUploadingImage(false) + return + } + } + + // Add URL if provided + if (formData.imageUrl) { + imageUrls.push(formData.imageUrl) + } + + // Limit to 4 images total + const finalImageUrls = imageUrls.slice(0, 4) + + // Update drop basic info const response = await fetch(`/api/drops/${editingDrop.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -106,22 +160,48 @@ export default function DropsManagementPage() { size: parseInt(formData.size), unit: formData.unit, ppu: parseInt(formData.ppu), - imageUrl: formData.imageUrl || null, + imageUrl: finalImageUrls[0] || null, // Keep first image for legacy support startTime: formData.startTime || null, }), }) - if (response.ok) { - alert('Drop updated successfully') - setEditingDrop(null) - fetchDrops() - } else { + if (!response.ok) { const error = await response.json() alert(`Error: ${error.error}`) + setUploadingImage(false) + return } + + // Save images (always save, even if empty array to clear all images) + const imagesResponse = await fetch('/api/drops/images', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + drop_id: editingDrop.id, + image_urls: finalImageUrls, + }), + }) + + if (!imagesResponse.ok) { + const error = await imagesResponse.json() + alert(`Error saving images: ${error.error}`) + setUploadingImage(false) + return + } + + alert('Drop updated successfully') + setEditingDrop(null) + setImageFiles([]) + setImagePreviews([]) + setExistingImages([]) + // Clean up preview URLs + imagePreviews.forEach(url => URL.revokeObjectURL(url)) + fetchDrops() } catch (error) { console.error('Error updating drop:', error) alert('Failed to update drop') + } finally { + setUploadingImage(false) } } @@ -149,26 +229,63 @@ export default function DropsManagementPage() { } const handleImageChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - setImageFile(file) - setImagePreview(URL.createObjectURL(file)) - // Clear the imageUrl field when a file is selected - setFormData({ ...formData, imageUrl: '' }) + const files = Array.from(e.target.files || []) + if (files.length > 0) { + // Calculate how many more images we can add (max 4 total) + const currentTotal = existingImages.length + imagePreviews.length + const remainingSlots = Math.max(0, 4 - currentTotal) + + if (remainingSlots === 0) { + alert('Maximum of 4 images allowed. Please remove some images first.') + // Clear the file input + e.target.value = '' + return + } + + // Limit to remaining slots + const limitedFiles = files.slice(0, remainingSlots) + setImageFiles([...imageFiles, ...limitedFiles]) + + // Create previews for new files + const newPreviews = limitedFiles.map(file => URL.createObjectURL(file)) + setImagePreviews([...imagePreviews, ...newPreviews]) + + // Clear the file input + e.target.value = '' } } + const removeImage = (index: number) => { + const newFiles = imageFiles.filter((_, i) => i !== index) + const newPreviews = imagePreviews.filter((_, i) => i !== index) + setImageFiles(newFiles) + setImagePreviews(newPreviews) + + // Revoke the URL to free memory + URL.revokeObjectURL(imagePreviews[index]) + } + + const removeExistingImage = (index: number) => { + const newImages = existingImages.filter((_, i) => i !== index) + setExistingImages(newImages) + } + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() try { - let imageUrl = formData.imageUrl || null + setUploadingImage(true) + const imageUrls: string[] = [] - // Upload image file if provided - if (imageFile) { - setUploadingImage(true) + // Add URL if provided + if (formData.imageUrl) { + imageUrls.push(formData.imageUrl) + } + + // Upload image files + for (const file of imageFiles) { const uploadFormData = new FormData() - uploadFormData.append('file', imageFile) + uploadFormData.append('file', file) const uploadResponse = await fetch('/api/upload', { method: 'POST', @@ -183,10 +300,13 @@ export default function DropsManagementPage() { } const uploadData = await uploadResponse.json() - imageUrl = uploadData.url - setUploadingImage(false) + imageUrls.push(uploadData.url) } + // Limit to 4 images + const finalImageUrls = imageUrls.slice(0, 4) + + // Create drop const response = await fetch('/api/drops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -195,32 +315,56 @@ export default function DropsManagementPage() { size: parseInt(formData.size), unit: formData.unit, ppu: parseInt(formData.ppu), - imageUrl: imageUrl, + imageUrl: finalImageUrls[0] || null, // Keep first image for legacy support startTime: formData.startTime || null, }), }) - if (response.ok) { - alert('Drop created successfully') - setFormData({ - item: '', - size: '', - unit: 'g', - ppu: '', - imageUrl: '', - startTime: '', - }) - setImageFile(null) - setImagePreview('') - // Clear file input - const fileInput = document.getElementById('imageFile') as HTMLInputElement - if (fileInput) fileInput.value = '' - setCreatingDrop(false) - fetchDrops() - } else { + if (!response.ok) { const error = await response.json() alert(`Error: ${error.error}`) + setUploadingImage(false) + return } + + const dropData = await response.json() + const dropId = dropData.id + + // Save multiple images if we have more than one + if (finalImageUrls.length > 0) { + const imagesResponse = await fetch('/api/drops/images', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + drop_id: dropId, + image_urls: finalImageUrls, + }), + }) + + if (!imagesResponse.ok) { + const error = await imagesResponse.json() + alert(`Drop created but failed to save images: ${error.error}`) + } + } + + alert('Drop created successfully') + setFormData({ + item: '', + size: '', + unit: 'g', + ppu: '', + imageUrl: '', + startTime: '', + }) + setImageFiles([]) + setImagePreviews([]) + // Clean up preview URLs + imagePreviews.forEach(url => URL.revokeObjectURL(url)) + // Clear file input + const fileInput = document.getElementById('imageFiles') as HTMLInputElement + if (fileInput) fileInput.value = '' + setCreatingDrop(false) + fetchDrops() } catch (error) { console.error('Error creating drop:', error) alert('Failed to create drop') @@ -296,9 +440,11 @@ export default function DropsManagementPage() { imageUrl: '', startTime: '', }) - setImageFile(null) - setImagePreview('') - const fileInput = document.getElementById('imageFile') as HTMLInputElement + setImageFiles([]) + setImagePreviews([]) + setExistingImages([]) + imagePreviews.forEach(url => URL.revokeObjectURL(url)) + const fileInput = document.getElementById('imageFiles') as HTMLInputElement if (fileInput) fileInput.value = '' } }} @@ -403,12 +549,15 @@ export default function DropsManagementPage() { />
- + - {imagePreview && ( -
- Preview + {(imagePreviews.length > 0 || existingImages.length > 0) && ( +
+ {existingImages.map((imgUrl, index) => ( +
+ {`Existing + +
+ ))} + {imagePreviews.map((preview, index) => ( +
+ {`Preview + +
+ ))}
)}

- Max file size: 5MB. Allowed formats: JPEG, PNG, WebP + Max 4 images. Max file size: 5MB each. Allowed formats: JPEG, PNG, WebP

- Or enter an image URL: + Or enter an image URL (will be added to uploaded images):

{ setFormData({ ...formData, imageUrl: e.target.value }) - // Clear file selection when URL is entered - if (e.target.value) { - setImageFile(null) - setImagePreview('') - const fileInput = document.getElementById('imageFile') as HTMLInputElement - if (fileInput) fileInput.value = '' - } }} placeholder="https://example.com/image.jpg" style={{ @@ -574,22 +781,6 @@ export default function DropsManagementPage() { }} />
-
- - setFormData({ ...formData, imageUrl: e.target.value })} - style={{ - width: '100%', - padding: '8px', - borderRadius: '8px', - border: '1px solid var(--border)', - background: 'var(--bg-soft)', - color: 'var(--text)' - }} - /> -
+
+ + + {(imagePreviews.length > 0 || existingImages.length > 0) && ( +
+ {existingImages.map((imgUrl, index) => ( +
+ {`Existing + +
+ ))} + {imagePreviews.map((preview, index) => ( +
+ {`Preview + +
+ ))} +
+ )} +

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

+

+ Or enter an image URL (will be added to uploaded images): +

+ { + setFormData({ ...formData, imageUrl: e.target.value }) + }} + placeholder="https://example.com/image.jpg" + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + marginTop: '8px' + }} + /> +
+ ))} +
+ )} + ) : (
)} - {!isUpcoming && hasRemaining && availableSizes.length > 0 && ( + {!isUpcoming && hasRemaining && ( <> -
- {availableSizes.map((size) => ( - - ))} +
+ {availableSizes.length > 0 && ( +
+ {availableSizes.map((size) => ( + + ))} +
+ )} +
0 ? '1' : '100%', minWidth: '150px' }}> + handleCustomQuantityChange(e.target.value)} + onBlur={validateCustomQuantity} + placeholder="Custom (g)" + min={getMinimumGrams()} + max={getRemainingInGrams()} + style={{ + width: '100%', + padding: '14px', + borderRadius: '12px', + border: `1px solid ${quantityError ? '#dc2626' : 'var(--border)'}`, + background: 'var(--bg-soft)', + color: 'var(--text)', + fontSize: '14px', + }} + /> + {quantityError && ( +
+ {quantityError} +
+ )} + {!quantityError && customQuantity && ( +
+ Min: {getMinimumGrams()}g · Max: {getRemainingInGrams()}g +
+ )} +
+
+ +
+ {isWholesaleUnlocked ? ( + <> +
+ Total: {calculatePrice().toFixed(2)} CHF +
+
+ Standard total: {calculateStandardPrice().toFixed(2)} CHF +
+ + ) : ( + <> +
+ Total: {calculateStandardPrice().toFixed(2)} CHF +
+
+ Wholesale total: {calculateWholesalePrice().toFixed(2)} CHF 🔒 +
+ + )}
+
+
+ setMobileMenuOpen(false)}>Drop + setMobileMenuOpen(false)}>Past Drops + setMobileMenuOpen(false)}>Community {!loading && ( user ? ( <> @@ -71,6 +83,7 @@ export default function Nav() { setMobileMenuOpen(false)} style={{ background: 'transparent', border: '1px solid var(--border)', @@ -89,7 +102,10 @@ export default function Nav() { Orders ) )} +
diff --git a/app/components/PastDrops.tsx b/app/components/PastDrops.tsx index 162fea3..8a79421 100644 --- a/app/components/PastDrops.tsx +++ b/app/components/PastDrops.tsx @@ -11,6 +11,7 @@ interface PastDrop { unit: string ppu: number image_url: string | null + images?: string[] // Array of image URLs (up to 4) created_at: string soldOutInHours: number } @@ -26,11 +27,21 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp useEffect(() => { fetchPastDrops() + + // Poll past drops every 30 seconds + const interval = setInterval(() => { + fetchPastDrops() + }, 30000) // 30 seconds + + return () => clearInterval(interval) }, []) const fetchPastDrops = async () => { try { - const response = await fetch('/api/drops/past') + const response = await fetch('/api/drops/past', { + // Add cache control to prevent stale data + cache: 'no-store', + }) if (response.ok) { const data = await response.json() setDrops(data) @@ -98,49 +109,59 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp return ( <>
- {displayedDrops.map((drop) => ( -
- {drop.image_url ? ( -
- {drop.item} { + // Get images array (prioritize new images array, fallback to legacy image_url) + const images = drop.images && drop.images.length > 0 + ? drop.images + : (drop.image_url ? [drop.image_url] : []) + + return ( +
+ {images.length > 0 ? ( +
1 ? '1fr 1fr' : '1fr', gap: '4px' }}> + {images.slice(0, 4).map((imgUrl, index) => ( + {`${drop.item} + ))} +
+ ) : ( +
-
- ) : ( -
- No Image -
- )} - {drop.item} -
- {formatSoldOutTime(drop.soldOutInHours)} -
- - {formatQuantity(drop.size, drop.unit)} · {formatDateAndTime(drop.created_at)} - -
- ))} + > + No Image +
+ )} + {drop.item} +
+ {formatSoldOutTime(drop.soldOutInHours)} +
+ + {formatQuantity(drop.size, drop.unit)} · {formatDateAndTime(drop.created_at)} + +
+ ) + })}
{showMoreLink && hasMore && (
diff --git a/app/components/UnlockBar.tsx b/app/components/UnlockBar.tsx index 91b9dfe..15ccc4c 100644 --- a/app/components/UnlockBar.tsx +++ b/app/components/UnlockBar.tsx @@ -46,14 +46,7 @@ export default function UnlockBar() { } if (loading) { - return ( -
- 🔒 Wholesale prices locked — Loading... -
- 3 verified sign-ups unlock wholesale prices forever. - Unlock now -
- ) + return null } const status = referralStatus || { @@ -67,7 +60,9 @@ export default function UnlockBar() { if (status.isUnlocked) { return (
- ✅ Wholesale prices unlocked — You have access to wholesale pricing! +
+ ✅ Wholesale prices unlocked — You have access to wholesale pricing! +
) } @@ -75,10 +70,12 @@ export default function UnlockBar() { return ( <>
- 🔒 Wholesale prices locked — {status.referralCount} / {status.referralsNeeded} referrals completed · {status.referralsRemaining} to go -
- {status.referralsNeeded} verified sign-ups unlock wholesale prices forever. - Unlock now +
+ 🔒 Wholesale prices locked — {status.referralCount} / {status.referralsNeeded} referrals completed · {status.referralsRemaining} to go +
+ {status.referralsNeeded} verified sign-ups unlock wholesale prices forever. + Unlock now +
setShowModal(false)} /> diff --git a/app/components/UnlockModal.tsx b/app/components/UnlockModal.tsx index 62e9f79..3e69a5e 100644 --- a/app/components/UnlockModal.tsx +++ b/app/components/UnlockModal.tsx @@ -1,12 +1,19 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, Suspense } from 'react' +import AuthModal from './AuthModal' interface UnlockModalProps { isOpen: boolean onClose: () => void } +interface User { + id: number + username: string + email: string +} + interface ReferralStatus { referralCount: number isUnlocked: boolean @@ -19,6 +26,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { const [referralLink, setReferralLink] = useState('') const [loading, setLoading] = useState(true) const [copied, setCopied] = useState(false) + const [showAuthModal, setShowAuthModal] = useState(false) useEffect(() => { if (isOpen) { @@ -63,6 +71,12 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { } } + const handleLogin = async (user: User) => { + setShowAuthModal(false) + // Refresh referral data after login + await fetchReferralData() + } + if (!isOpen) return null const status = referralStatus || { @@ -192,11 +206,26 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { borderRadius: '8px', marginBottom: '24px', textAlign: 'center', - color: 'var(--muted)', - fontSize: '14px', }} > - Please log in to get your referral link +

+ Please log in to get your referral link +

+
)} @@ -245,6 +274,15 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { )} + + {/* Auth Modal */} + + setShowAuthModal(false)} + onLogin={handleLogin} + /> + ) } diff --git a/app/globals.css b/app/globals.css index 33fffdb..750e8b5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,7 +32,14 @@ nav { background: rgba(14, 14, 14, 0.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--border); - padding: 20px 40px; + padding: 20px; + display: flex; + justify-content: center; +} + +nav > div { + max-width: 1200px; + width: 100%; display: flex; justify-content: space-between; align-items: center; @@ -44,6 +51,24 @@ nav .brand { letter-spacing: 0.5px; } +.mobile-menu-toggle { + display: none; + background: transparent; + border: 1px solid var(--border); + color: var(--text); + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +nav .links { + display: flex; + align-items: center; + flex-wrap: wrap; +} + nav .links a { margin-left: 28px; font-size: 14px; @@ -54,6 +79,60 @@ nav .links a:hover { color: var(--text); } +@media (max-width: 768px) { + nav > div { + flex-direction: column; + align-items: flex-start; + } + + nav > div > div:first-child { + width: 100%; + justify-content: space-between; + } + + .mobile-menu-toggle { + display: block; + } + + nav .links { + display: none; + flex-direction: column; + width: 100%; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--border); + gap: 16px; + } + + nav .links.mobile-open { + display: flex; + } + + nav .links a { + margin-left: 0; + padding: 12px 0; + width: 100%; + text-align: left; + border-bottom: 1px solid var(--border); + } + + nav .links a:last-child { + border-bottom: none; + } + + nav .links span, + nav .links button { + margin-left: 0 !important; + margin-top: 12px; + width: 100%; + text-align: left; + } + + nav .links button { + justify-content: flex-start; + } +} + .unlock-bar { background: var(--bg-soft); border-bottom: 1px solid var(--border); @@ -61,6 +140,13 @@ nav .links a:hover { font-size: 14px; color: var(--muted); text-align: center; + display: flex; + justify-content: center; +} + +.unlock-bar > div { + max-width: 1200px; + width: 100%; } .unlock-bar strong { diff --git a/lib/payment-currencies.ts b/lib/payment-currencies.ts new file mode 100644 index 0000000..1d63376 --- /dev/null +++ b/lib/payment-currencies.ts @@ -0,0 +1,27 @@ +/** + * Allowed payment cryptocurrencies + * + * Edit this list to add or remove supported payment currencies. + * All currencies should be in lowercase. + */ +export const ALLOWED_PAYMENT_CURRENCIES = [ + 'btc', + 'eth', + 'sol', + 'xrp', + 'bnbbsc', + 'usdterc20', +] as const + +/** + * Type for allowed payment currency + */ +export type AllowedPaymentCurrency = typeof ALLOWED_PAYMENT_CURRENCIES[number] + +/** + * Check if a currency is in the allowed list + */ +export function isAllowedCurrency(currency: string): boolean { + return ALLOWED_PAYMENT_CURRENCIES.includes(currency.toLowerCase() as AllowedPaymentCurrency) +} + diff --git a/migrations/create_drop_images.sql b/migrations/create_drop_images.sql new file mode 100644 index 0000000..333d2ec --- /dev/null +++ b/migrations/create_drop_images.sql @@ -0,0 +1,17 @@ +-- Migration: Create drop_images table for multiple images per drop +-- This allows up to 4 images per drop + +CREATE TABLE IF NOT EXISTS `drop_images` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `drop_id` int(11) NOT NULL, + `image_url` varchar(255) NOT NULL, + `display_order` int(11) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `drop_id` (`drop_id`), + CONSTRAINT `drop_images_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- Add index for faster queries +CREATE INDEX idx_drop_images_drop_order ON drop_images(drop_id, display_order); +