Initial commit – Cursa de la Cirera 2026 e-commerce
Next.js 14 + Prisma + Stripe + Google Sheets + Nodemailer
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.local.example
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
dev.db
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# CURSA DE LA CIRERA 2026 – Variables d'entorn
|
||||||
|
# Copia aquest fitxer a .env.local i omple els valors
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# URL base de l'aplicació (sense barra final)
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://cursacorbins2026.treblarella.org
|
||||||
|
|
||||||
|
# ─── Base de dades (SQLite) ────────────────────────────────────
|
||||||
|
# Desenvolupament local:
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
# Producció (Docker volume):
|
||||||
|
# DATABASE_URL="file:/data/db.sqlite"
|
||||||
|
|
||||||
|
# ─── Stripe ───────────────────────────────────────────────────
|
||||||
|
# Claus des del dashboard de Stripe → Developers → API Keys
|
||||||
|
STRIPE_SECRET_KEY=sk_live_XXXXXXXXXXXXXXXXXXXX
|
||||||
|
# Per a tests locals usa: sk_test_XXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
|
# Secret del webhook de Stripe → Developers → Webhooks
|
||||||
|
# En desenvolupament: stripe listen --forward-to localhost:3000/api/webhook
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
|
# ─── Email (SMTP) ─────────────────────────────────────────────
|
||||||
|
# Configuració del servidor SMTP per enviar emails
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=el-teu-email@gmail.com
|
||||||
|
SMTP_PASS=la-teva-contrasenya-o-app-password
|
||||||
|
# Email remitent (opcional, per defecte usa SMTP_USER)
|
||||||
|
SMTP_FROM=Blood Bros Sport <noreply@bloodbrossport.com>
|
||||||
|
|
||||||
|
# Email on rebre les notificacions de noves comandes
|
||||||
|
ADMIN_EMAIL=albert.gadea@gmail.com
|
||||||
|
|
||||||
|
# ─── Admin Panel ──────────────────────────────────────────────
|
||||||
|
# Contrasenya per accedir a /admin
|
||||||
|
ADMIN_PASSWORD=canvia-aquesta-contrasenya-segura
|
||||||
|
|
||||||
|
# ─── Google Sheets ────────────────────────────────────────────
|
||||||
|
# ID del Google Sheet (de la URL: /spreadsheets/d/AQUI/edit)
|
||||||
|
GOOGLE_SHEET_ID=
|
||||||
|
# Credencials del Service Account en base64
|
||||||
|
# PowerShell: [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Content credentials.json -Raw)))
|
||||||
|
GOOGLE_CREDENTIALS_BASE64=
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# sqlite
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
/prisma/migrations/
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# ─── Stage 1: Build ───────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install OpenSSL (required by Prisma)
|
||||||
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Copy assets to public (for Next.js static serving)
|
||||||
|
RUN mkdir -p public/assets && cp -r assets/. public/assets/
|
||||||
|
|
||||||
|
# Generate Prisma client & build
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: Runner ──────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /data && chown nextjs:nodejs /data
|
||||||
|
|
||||||
|
# Copy build artifacts
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy Prisma files (needed to run migrations at startup)
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run DB migrations then start the app
|
||||||
|
CMD ["sh", "-c", "node_modules/.bin/prisma db push --skip-generate && node server.js"]
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=file:/data/db.sqlite
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
|
- SMTP_SECURE=${SMTP_SECURE}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- SMTP_FROM=${SMTP_FROM}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "cursa-corbins-2026",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.17.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"googleapis": "^173.0.0",
|
||||||
|
"lucide-react": "^0.408.0",
|
||||||
|
"next": "14.2.5",
|
||||||
|
"nodemailer": "^6.9.14",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"stripe": "^16.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^6.4.15",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.5",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prisma": "^5.17.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderNumber String @unique
|
||||||
|
|
||||||
|
// Product
|
||||||
|
product String // "samarreta" | "mitjons" | "pack"
|
||||||
|
sizeTshirt String? // S | M | L | XL | XXL | 3XL
|
||||||
|
sizeSocks String? // XXS | XS | S | M | L | XL
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
baseAmount Float
|
||||||
|
shippingAmount Float @default(0)
|
||||||
|
totalAmount Float
|
||||||
|
|
||||||
|
// Customer data
|
||||||
|
nom String
|
||||||
|
cognoms String
|
||||||
|
telefon String
|
||||||
|
email String
|
||||||
|
|
||||||
|
// Shipping address (required for Correos, optional for pickup)
|
||||||
|
adreca String?
|
||||||
|
codiPostal String?
|
||||||
|
poblacio String?
|
||||||
|
provincia String?
|
||||||
|
|
||||||
|
// Delivery method
|
||||||
|
shipping String // "correos" | "recollida"
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
stripeSessionId String? @unique
|
||||||
|
stripePaymentId String?
|
||||||
|
status String @default("PENDING")
|
||||||
|
// PENDING | PAID | PREPARING | SHIPPED | DELIVERED | CANCELLED
|
||||||
|
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,472 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import {
|
||||||
|
Download, RefreshCw, LogOut, Search, Package,
|
||||||
|
ChevronDown, Check, X, ShoppingBag, Truck, Users, Euro, Sheet,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string
|
||||||
|
orderNumber: string
|
||||||
|
createdAt: string
|
||||||
|
status: string
|
||||||
|
product: string
|
||||||
|
sizeTshirt?: string
|
||||||
|
sizeSocks?: string
|
||||||
|
nom: string
|
||||||
|
cognoms: string
|
||||||
|
email: string
|
||||||
|
telefon: string
|
||||||
|
shipping: string
|
||||||
|
adreca?: string
|
||||||
|
codiPostal?: string
|
||||||
|
poblacio?: string
|
||||||
|
provincia?: string
|
||||||
|
baseAmount: number
|
||||||
|
shippingAmount: number
|
||||||
|
totalAmount: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_SHORT: Record<string, string> = {
|
||||||
|
samarreta: 'Samarreta',
|
||||||
|
mitjons: 'Mitjons',
|
||||||
|
pack: 'Pack S+M',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = ['PENDING', 'PAID', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CANCELLED']
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Pendent',
|
||||||
|
PAID: 'Pagat',
|
||||||
|
PREPARING: 'Preparant',
|
||||||
|
SHIPPED: 'Enviat',
|
||||||
|
DELIVERED: 'Lliurat',
|
||||||
|
CANCELLED: 'Cancel·lat',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<span className={clsx('status-badge', `status-${status}`)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsCard({ icon: Icon, label, value, color = 'text-teal' }: { icon: React.ElementType; label: string; value: string | number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-dark-card border border-dark-border rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<div className={`${color} bg-dark rounded-lg p-2.5`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-black text-white">{value}</div>
|
||||||
|
<div className="text-slate-500 text-xs">{label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [authed, setAuthed] = useState(false)
|
||||||
|
const [authError, setAuthError] = useState('')
|
||||||
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL')
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [syncMsg, setSyncMsg] = useState('')
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async (pwd: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/orders', {
|
||||||
|
headers: { 'x-admin-password': pwd },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('No autoritzat')
|
||||||
|
const data = await res.json()
|
||||||
|
setOrders(data.orders)
|
||||||
|
} catch {
|
||||||
|
setAuthError('Contrasenya incorrecta')
|
||||||
|
setAuthed(false)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleLogin(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setAuthError('')
|
||||||
|
localStorage.setItem('admin-pwd', password)
|
||||||
|
setAuthed(true)
|
||||||
|
fetchOrders(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('admin-pwd')
|
||||||
|
if (saved) {
|
||||||
|
setPassword(saved)
|
||||||
|
setAuthed(true)
|
||||||
|
fetchOrders(saved)
|
||||||
|
}
|
||||||
|
}, [fetchOrders])
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('admin-pwd')
|
||||||
|
setPassword('')
|
||||||
|
setAuthed(false)
|
||||||
|
setOrders([])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(orderId: string, status: string) {
|
||||||
|
setUpdatingId(orderId)
|
||||||
|
try {
|
||||||
|
await fetch('/api/orders', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-admin-password': password,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: orderId, status }),
|
||||||
|
})
|
||||||
|
setOrders((prev) =>
|
||||||
|
prev.map((o) => (o.id === orderId ? { ...o, status } : o))
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setUpdatingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncSheets() {
|
||||||
|
setSyncing(true)
|
||||||
|
setSyncMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/orders/sync-sheets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-admin-password': password },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) setSyncMsg(`✓ ${data.synced} comandes sincronitzades`)
|
||||||
|
else setSyncMsg('Error en sincronitzar')
|
||||||
|
} catch {
|
||||||
|
setSyncMsg('Error de connexió')
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
setTimeout(() => setSyncMsg(''), 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
const url = `/api/orders?format=csv`
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.setAttribute('data-password', password)
|
||||||
|
// Use fetch to handle auth header
|
||||||
|
fetch(url, { headers: { 'x-admin-password': password } })
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const burl = URL.createObjectURL(blob)
|
||||||
|
a.href = burl
|
||||||
|
a.download = `comandes-cc2026-${new Date().toISOString().split('T')[0]}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(burl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered orders
|
||||||
|
const filtered = orders.filter((o) => {
|
||||||
|
if (statusFilter !== 'ALL' && o.status !== statusFilter) return false
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
o.orderNumber.toLowerCase().includes(q) ||
|
||||||
|
o.nom.toLowerCase().includes(q) ||
|
||||||
|
o.cognoms.toLowerCase().includes(q) ||
|
||||||
|
o.email.toLowerCase().includes(q) ||
|
||||||
|
o.telefon.includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const paid = orders.filter((o) => o.status !== 'PENDING' && o.status !== 'CANCELLED')
|
||||||
|
const totalRevenue = paid.reduce((sum, o) => sum + o.totalAmount, 0)
|
||||||
|
const countByProduct = {
|
||||||
|
samarreta: orders.filter((o) => o.product === 'samarreta' && o.status !== 'CANCELLED').length,
|
||||||
|
mitjons: orders.filter((o) => o.product === 'mitjons' && o.status !== 'CANCELLED').length,
|
||||||
|
pack: orders.filter((o) => o.product === 'pack' && o.status !== 'CANCELLED').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────
|
||||||
|
if (!authed) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: '#070714' }}>
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Image
|
||||||
|
src="/assets/LOGO BLOD BROS SPORT GOTA RODONA ALTA RES.jpg"
|
||||||
|
alt="Blood Bros Sport"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="rounded-full mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
<h1 className="text-white font-black text-2xl">Panel d'administració</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Cursa de la Cirera 2026</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleLogin} className="bg-dark-card border border-dark-border rounded-2xl p-6">
|
||||||
|
<label className="form-label">Contrasenya</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input-field mb-4"
|
||||||
|
placeholder="Contrasenya d'administrador"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{authError && (
|
||||||
|
<p className="text-cherry text-sm mb-4 flex items-center gap-1">
|
||||||
|
<X size={14} /> {authError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-pay"
|
||||||
|
disabled={!password}
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin panel ──────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: '#070714' }}>
|
||||||
|
{/* Topbar */}
|
||||||
|
<header className="border-b border-dark-border bg-dark/90 backdrop-blur-md sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image
|
||||||
|
src="/assets/LOGO BLOD BROS SPORT GOTA RODONA ALTA RES.jpg"
|
||||||
|
alt="Blood Bros Sport"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-bold text-sm leading-tight">Admin Panel</div>
|
||||||
|
<div className="text-slate-500 text-xs">Cursa de la Cirera 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchOrders(password)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white transition-colors"
|
||||||
|
title="Actualitzar"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSyncSheets}
|
||||||
|
disabled={syncing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/30 text-green-400 rounded-lg text-xs hover:bg-green-500/20 transition-colors disabled:opacity-50"
|
||||||
|
title="Sincronitzar amb Google Sheets"
|
||||||
|
>
|
||||||
|
<Sheet size={14} className={syncing ? 'animate-spin' : ''} />
|
||||||
|
{syncing ? 'Sincronitzant...' : syncMsg || 'Sheets'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-teal/10 border border-teal/30 text-teal rounded-lg text-xs hover:bg-teal/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 text-slate-500 hover:text-white transition-colors"
|
||||||
|
title="Sortir"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard icon={Users} label="Comandes totals" value={orders.length} />
|
||||||
|
<StatsCard icon={Euro} label="Ingressos" value={`${totalRevenue.toFixed(2).replace('.', ',')}€`} color="text-cherry" />
|
||||||
|
<StatsCard icon={ShoppingBag} label="Samarretes + Packs" value={countByProduct.samarreta + countByProduct.pack} />
|
||||||
|
<StatsCard icon={Package} label="Mitjons + Packs" value={countByProduct.mitjons + countByProduct.pack} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product breakdown */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-8">
|
||||||
|
{Object.entries(PRODUCT_SHORT).map(([key, label]) => (
|
||||||
|
<div key={key} className="bg-dark-card border border-dark-border rounded-xl p-3 text-center">
|
||||||
|
<div className="text-2xl font-black text-white">{countByProduct[key as keyof typeof countByProduct]}</div>
|
||||||
|
<div className="text-slate-500 text-xs">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status stats */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{['ALL', ...STATUSES].map((s) => {
|
||||||
|
const count = s === 'ALL' ? orders.length : orders.filter((o) => o.status === s).length
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-all',
|
||||||
|
statusFilter === s
|
||||||
|
? 'bg-teal border-teal text-dark'
|
||||||
|
: 'bg-transparent border-dark-border text-slate-400 hover:border-dark-muted'
|
||||||
|
)}
|
||||||
|
style={statusFilter === s ? { color: '#000' } : undefined}
|
||||||
|
>
|
||||||
|
{s === 'ALL' ? 'Totes' : STATUS_LABELS[s]}
|
||||||
|
<span className={clsx('text-[10px]', statusFilter === s ? 'opacity-70' : 'text-slate-600')}>
|
||||||
|
({count})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field pl-9"
|
||||||
|
placeholder="Cerca per nom, email, telèfon o número de comanda..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-10 h-10 border-4 border-teal border-t-transparent rounded-full animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-slate-600">
|
||||||
|
{search || statusFilter !== 'ALL' ? 'Cap resultat per als filtres aplicats.' : 'No hi ha comandes encara.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((order) => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
className="bg-dark-card border border-dark-border rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Row summary */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(expandedId === order.id ? null : order.id)}
|
||||||
|
className="w-full text-left px-5 py-4 flex flex-wrap items-center gap-4 hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-teal font-black text-sm w-28 shrink-0">
|
||||||
|
{order.orderNumber}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
<span className="text-white text-sm font-medium flex-1 min-w-32">
|
||||||
|
{order.nom} {order.cognoms}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 text-sm hidden sm:block">
|
||||||
|
{PRODUCT_SHORT[order.product] ?? order.product}
|
||||||
|
{order.sizeTshirt && ` · S${order.sizeTshirt}`}
|
||||||
|
{order.sizeSocks && ` · M${order.sizeSocks}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-bold text-sm ml-auto">
|
||||||
|
{order.totalAmount.toFixed(2).replace('.', ',')}€
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={clsx('text-slate-500 shrink-0 transition-transform', expandedId === order.id && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail */}
|
||||||
|
{expandedId === order.id && (
|
||||||
|
<div className="border-t border-dark-border px-5 py-4 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Contact */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Client</p>
|
||||||
|
<p className="text-white text-sm font-semibold">{order.nom} {order.cognoms}</p>
|
||||||
|
<p className="text-slate-400 text-sm">{order.email}</p>
|
||||||
|
<p className="text-slate-400 text-sm">{order.telefon}</p>
|
||||||
|
<p className="text-slate-500 text-xs mt-1">
|
||||||
|
{new Date(order.createdAt).toLocaleDateString('ca-ES', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Lliurament</p>
|
||||||
|
<p className="text-white text-sm font-semibold">
|
||||||
|
{order.shipping === 'correos' ? 'Correos Express' : 'Recollida Corbins/Lleida'}
|
||||||
|
</p>
|
||||||
|
{order.shipping === 'correos' && order.adreca && (
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
{order.adreca}<br />
|
||||||
|
{order.codiPostal} {order.poblacio}<br />
|
||||||
|
{order.provincia}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status update */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Canviar estat</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => updateStatus(order.id, s)}
|
||||||
|
disabled={updatingId === order.id || order.status === s}
|
||||||
|
className={clsx(
|
||||||
|
'px-2.5 py-1 rounded-lg text-xs font-semibold border transition-all',
|
||||||
|
order.status === s
|
||||||
|
? 'bg-teal/20 border-teal text-teal'
|
||||||
|
: 'bg-transparent border-dark-border text-slate-400 hover:border-dark-muted hover:text-white',
|
||||||
|
updatingId === order.id && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{order.status === s && <Check size={10} className="inline mr-1" />}
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-slate-700 text-xs mt-8">
|
||||||
|
{filtered.length} comanda{filtered.length !== 1 ? 'es' : ''} mostrade{filtered.length !== 1 ? 's' : ''}
|
||||||
|
{orders.length !== filtered.length && ` de ${orders.length} totals`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { stripe } from '@/lib/stripe'
|
||||||
|
import { generateOrderNumber } from '@/lib/orderNumber'
|
||||||
|
|
||||||
|
const PRODUCT_NAMES: Record<string, string> = {
|
||||||
|
samarreta: 'Samarreta Cursa de la Cirera 2026',
|
||||||
|
mitjons: 'Mitjons Cursa de la Cirera 2026',
|
||||||
|
pack: 'Pack Samarreta + Mitjons Cursa de la Cirera 2026',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_PRICES: Record<string, number> = {
|
||||||
|
samarreta: 1000,
|
||||||
|
mitjons: 1000,
|
||||||
|
pack: 1850,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIPPING_PRICE = 799
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const {
|
||||||
|
product, sizeTshirt, sizeSocks, shipping,
|
||||||
|
nom, cognoms, telefon, email,
|
||||||
|
adreca, codiPostal, poblacio, provincia,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate product
|
||||||
|
if (!product || !PRODUCT_PRICES[product]) {
|
||||||
|
return NextResponse.json({ error: 'Producte no vàlid' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!['correos', 'recollida'].includes(shipping)) {
|
||||||
|
return NextResponse.json({ error: 'Mètode de lliurament no vàlid' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!nom || !cognoms || !telefon || !email) {
|
||||||
|
return NextResponse.json({ error: 'Falten dades obligatòries' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAmountCents = PRODUCT_PRICES[product]
|
||||||
|
const shippingAmountCents = shipping === 'correos' ? SHIPPING_PRICE : 0
|
||||||
|
const totalCents = baseAmountCents + shippingAmountCents
|
||||||
|
|
||||||
|
const orderNumber = await generateOrderNumber()
|
||||||
|
|
||||||
|
// Build Stripe line items
|
||||||
|
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: 'eur',
|
||||||
|
product_data: {
|
||||||
|
name: PRODUCT_NAMES[product],
|
||||||
|
description: [
|
||||||
|
sizeTshirt ? `Samarreta talla ${sizeTshirt}` : '',
|
||||||
|
sizeSocks ? `Mitjons talla ${sizeSocks}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || undefined,
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
unit_amount: baseAmountCents,
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (shippingAmountCents > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
price_data: {
|
||||||
|
currency: 'eur',
|
||||||
|
product_data: { name: 'Enviament Correos Express' },
|
||||||
|
unit_amount: shippingAmountCents,
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
|
||||||
|
|
||||||
|
// Create Stripe checkout session first to get the session ID
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: 'payment',
|
||||||
|
line_items: lineItems,
|
||||||
|
customer_email: email,
|
||||||
|
locale: 'es',
|
||||||
|
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${baseUrl}/?cancelled=true`,
|
||||||
|
metadata: { orderNumber },
|
||||||
|
payment_intent_data: {
|
||||||
|
description: `${orderNumber} – ${PRODUCT_NAMES[product]}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create order in DB
|
||||||
|
await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNumber,
|
||||||
|
product,
|
||||||
|
sizeTshirt: sizeTshirt ?? null,
|
||||||
|
sizeSocks: sizeSocks ?? null,
|
||||||
|
baseAmount: baseAmountCents / 100,
|
||||||
|
shippingAmount: shippingAmountCents / 100,
|
||||||
|
totalAmount: totalCents / 100,
|
||||||
|
nom,
|
||||||
|
cognoms,
|
||||||
|
telefon,
|
||||||
|
email,
|
||||||
|
adreca: adreca ?? null,
|
||||||
|
codiPostal: codiPostal ?? null,
|
||||||
|
poblacio: poblacio ?? null,
|
||||||
|
provincia: provincia ?? null,
|
||||||
|
shipping,
|
||||||
|
stripeSessionId: session.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ url: session.url })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[checkout]', err)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error intern. Torna-ho a intentar.' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sessionId = new URL(req.url).searchParams.get('session_id')
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json({ order: null }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { stripeSessionId: sessionId },
|
||||||
|
select: {
|
||||||
|
orderNumber: true,
|
||||||
|
nom: true,
|
||||||
|
cognoms: true,
|
||||||
|
email: true,
|
||||||
|
product: true,
|
||||||
|
sizeTshirt: true,
|
||||||
|
sizeSocks: true,
|
||||||
|
shipping: true,
|
||||||
|
totalAmount: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ order })
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendStatusUpdateToClient } from '@/lib/email'
|
||||||
|
|
||||||
|
function checkAdmin(req: NextRequest): boolean {
|
||||||
|
const password = req.headers.get('x-admin-password')
|
||||||
|
return password === process.env.ADMIN_PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/orders — list all orders (admin only)
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!checkAdmin(req)) {
|
||||||
|
return NextResponse.json({ error: 'No autoritzat' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const format = searchParams.get('format')
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const cols = [
|
||||||
|
'orderNumber', 'createdAt', 'status', 'product', 'sizeTshirt', 'sizeSocks',
|
||||||
|
'nom', 'cognoms', 'email', 'telefon',
|
||||||
|
'shipping', 'adreca', 'codiPostal', 'poblacio', 'provincia',
|
||||||
|
'baseAmount', 'shippingAmount', 'totalAmount',
|
||||||
|
]
|
||||||
|
const header = cols.join(';')
|
||||||
|
const rows = orders.map((o) =>
|
||||||
|
cols
|
||||||
|
.map((c) => {
|
||||||
|
const val = (o as Record<string, unknown>)[c]
|
||||||
|
if (val instanceof Date) return val.toISOString().split('T')[0]
|
||||||
|
if (typeof val === 'number') return String(val).replace('.', ',')
|
||||||
|
return String(val ?? '').replace(/;/g, ',')
|
||||||
|
})
|
||||||
|
.join(';')
|
||||||
|
)
|
||||||
|
const csv = [header, ...rows].join('\n')
|
||||||
|
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="comandes-cc2026-${new Date().toISOString().split('T')[0]}.csv"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ orders })
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/orders — update order status
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
if (!checkAdmin(req)) {
|
||||||
|
return NextResponse.json({ error: 'No autoritzat' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, status, notes } = await req.json()
|
||||||
|
|
||||||
|
const validStatuses = ['PENDING', 'PAID', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CANCELLED']
|
||||||
|
if (!id || !validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: 'Dades no vàlides' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
...(notes !== undefined ? { notes } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify client on key status changes
|
||||||
|
if (['SHIPPED', 'DELIVERED', 'PREPARING'].includes(status)) {
|
||||||
|
await sendStatusUpdateToClient(order).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ order })
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { syncAllOrdersToSheet } from '@/lib/sheets'
|
||||||
|
|
||||||
|
function checkAdmin(req: NextRequest): boolean {
|
||||||
|
return req.headers.get('x-admin-password') === process.env.ADMIN_PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!checkAdmin(req)) {
|
||||||
|
return NextResponse.json({ error: 'No autoritzat' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = await syncAllOrdersToSheet(orders)
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, synced: count })
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { stripe } from '@/lib/stripe'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
sendOrderConfirmationToClient,
|
||||||
|
sendNewOrderNotificationToAdmin,
|
||||||
|
} from '@/lib/email'
|
||||||
|
import { appendOrderToSheet } from '@/lib/sheets'
|
||||||
|
|
||||||
|
// Required: raw body for Stripe signature verification
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const payload = await req.text()
|
||||||
|
const sig = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
|
if (!sig) {
|
||||||
|
return NextResponse.json({ error: 'No signature' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: ReturnType<typeof stripe.webhooks.constructEvent>
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
payload,
|
||||||
|
sig,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!
|
||||||
|
)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Webhook error'
|
||||||
|
console.error('[webhook] Signature verification failed:', msg)
|
||||||
|
return NextResponse.json({ error: `Webhook error: ${msg}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
const session = event.data.object
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { stripeSessionId: session.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.error('[webhook] Order not found for session:', session.id)
|
||||||
|
return NextResponse.json({ received: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'PENDING') {
|
||||||
|
// Already processed (idempotent)
|
||||||
|
return NextResponse.json({ received: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
status: 'PAID',
|
||||||
|
stripePaymentId: session.payment_intent as string ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send emails + sync to Google Sheets (non-blocking)
|
||||||
|
await Promise.allSettled([
|
||||||
|
sendOrderConfirmationToClient(updated),
|
||||||
|
sendNewOrderNotificationToAdmin(updated),
|
||||||
|
appendOrderToSheet(updated),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #070714;
|
||||||
|
--color-card: #0E0E1F;
|
||||||
|
--color-border: #1C1C38;
|
||||||
|
--color-teal: #00C8DC;
|
||||||
|
--color-red: #E84040;
|
||||||
|
--color-text: #ffffff;
|
||||||
|
--color-muted: #8888AA;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-teal);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input base styles */
|
||||||
|
.input-field {
|
||||||
|
@apply w-full bg-dark-card border border-dark-border rounded-lg px-4 py-3 text-white placeholder-dark-muted
|
||||||
|
focus:outline-none focus:border-teal focus:ring-1 focus:ring-teal transition-all duration-200
|
||||||
|
text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:hover {
|
||||||
|
border-color: #2A2A55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select field */
|
||||||
|
select.input-field {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238888AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
|
background-position: right 12px center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 16px;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label */
|
||||||
|
.form-label {
|
||||||
|
@apply block text-xs font-semibold text-slate-400 mb-1.5 uppercase tracking-wide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product card */
|
||||||
|
.product-card {
|
||||||
|
@apply relative bg-dark-card border-2 border-dark-border rounded-2xl p-6 cursor-pointer
|
||||||
|
transition-all duration-300 hover:border-teal/50 hover:shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card.selected {
|
||||||
|
@apply border-teal shadow-teal/20;
|
||||||
|
box-shadow: 0 0 0 1px #00C8DC, 0 8px 32px rgba(0, 200, 220, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size button */
|
||||||
|
.size-btn {
|
||||||
|
@apply px-3 py-2 rounded-lg border border-dark-border text-sm font-semibold
|
||||||
|
transition-all duration-200 hover:border-teal/60 cursor-pointer text-slate-300;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-btn.selected {
|
||||||
|
@apply bg-teal text-dark border-teal;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-btn:hover:not(.selected) {
|
||||||
|
@apply text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shipping card */
|
||||||
|
.shipping-card {
|
||||||
|
@apply flex-1 bg-dark-card border-2 border-dark-border rounded-xl p-5 cursor-pointer
|
||||||
|
transition-all duration-300 hover:border-teal/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipping-card.selected {
|
||||||
|
@apply border-teal;
|
||||||
|
box-shadow: 0 0 0 1px #00C8DC, 0 4px 24px rgba(0, 200, 220, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.section-title {
|
||||||
|
@apply text-xs font-black tracking-[0.3em] uppercase text-teal mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #00C8DC 0%, #00A8C0 50%, #E84040 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pay button */
|
||||||
|
.btn-pay {
|
||||||
|
@apply w-full py-4 px-8 rounded-xl font-black text-lg text-white tracking-wider uppercase
|
||||||
|
transition-all duration-300 relative overflow-hidden;
|
||||||
|
background: linear-gradient(135deg, #E84040 0%, #C82020 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pay:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 32px rgba(232, 64, 64, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pay:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pay:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown digits */
|
||||||
|
.countdown-digit {
|
||||||
|
@apply bg-dark-card border border-dark-border rounded-xl flex flex-col items-center justify-center;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-digit .number {
|
||||||
|
@apply text-4xl font-black text-white leading-none tabular-nums;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-digit .label {
|
||||||
|
@apply text-xs text-slate-500 uppercase tracking-widest mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse glow animation for teal elements */
|
||||||
|
@keyframes glowPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(0, 200, 220, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(0, 200, 220, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-pulse {
|
||||||
|
animation: glowPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating animation for product images */
|
||||||
|
@keyframes floatAnim {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
33% { transform: translateY(-10px) rotate(0.5deg); }
|
||||||
|
66% { transform: translateY(-5px) rotate(-0.5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-anim {
|
||||||
|
animation: floatAnim 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide reveal on scroll */
|
||||||
|
@keyframes revealUp {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-up {
|
||||||
|
animation: revealUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shimmer loading effect */
|
||||||
|
@keyframes shimmerAnim {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
background: linear-gradient(90deg, #1C1C38 25%, #2A2A45 50%, #1C1C38 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmerAnim 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
@apply text-xs font-black uppercase tracking-widest px-3 py-1 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-teal {
|
||||||
|
@apply text-dark bg-teal;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-red {
|
||||||
|
@apply text-white bg-cherry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trust badge card */
|
||||||
|
.trust-card {
|
||||||
|
@apply flex flex-col items-center gap-3 p-5 bg-dark-card border border-dark-border rounded-xl text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section divider */
|
||||||
|
.section-divider {
|
||||||
|
@apply border-t border-dark-border my-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step indicator */
|
||||||
|
.step-dot {
|
||||||
|
@apply w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-black transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.active {
|
||||||
|
@apply bg-teal border-teal text-dark;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.done {
|
||||||
|
@apply bg-teal/20 border-teal/50 text-teal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.pending {
|
||||||
|
@apply bg-transparent border-dark-muted text-dark-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin table */
|
||||||
|
.admin-table {
|
||||||
|
@apply w-full text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
@apply text-left text-xs font-semibold text-slate-500 uppercase tracking-wider py-3 px-4 border-b border-dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
@apply py-3 px-4 border-b border-dark-border/50 text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.status-badge {
|
||||||
|
@apply text-xs font-bold uppercase tracking-wider px-2.5 py-1 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-PENDING { @apply bg-yellow-500/15 text-yellow-400; }
|
||||||
|
.status-PAID { @apply bg-teal/15 text-teal; }
|
||||||
|
.status-PREPARING { @apply bg-blue-500/15 text-blue-400; }
|
||||||
|
.status-SHIPPED { @apply bg-purple-500/15 text-purple-400; }
|
||||||
|
.status-DELIVERED { @apply bg-green-500/15 text-green-400; }
|
||||||
|
.status-CANCELLED { @apply bg-red-500/15 text-red-400; }
|
||||||
|
|
||||||
|
/* Confetti animation for success page */
|
||||||
|
@keyframes confettiFall {
|
||||||
|
0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
|
||||||
|
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-piece {
|
||||||
|
position: fixed;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
top: -10px;
|
||||||
|
animation: confettiFall linear forwards;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.countdown-digit {
|
||||||
|
min-width: 56px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
}
|
||||||
|
.countdown-digit .number {
|
||||||
|
@apply text-3xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Cursa de la Cirera 2026 · Corbins – Samarreta i Mitjons oficials',
|
||||||
|
description:
|
||||||
|
'Compra la samarreta i els mitjons oficials de la Cursa de la Cirera de Corbins 2026. Comandes del 18 al 25 de juny. Lliurament 29 juny – 3 juliol 2026.',
|
||||||
|
keywords: 'Cursa de la Cirera, Corbins, 2026, samarreta running, Blood Bros Sport',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Cursa de la Cirera 2026 · Corbins',
|
||||||
|
description: 'Samarreta i mitjons oficials. Comandes del 18 al 25 de juny.',
|
||||||
|
type: 'website',
|
||||||
|
locale: 'ca_ES',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="ca">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,785 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import { Ruler, ChevronDown, Check, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
|
import Header from '@/components/Header'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import CountdownBanner from '@/components/CountdownBanner'
|
||||||
|
import TrustSection from '@/components/TrustSection'
|
||||||
|
import SizeGuideModal from '@/components/SizeGuideModal'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRODUCTS = {
|
||||||
|
samarreta: {
|
||||||
|
id: 'samarreta',
|
||||||
|
name: 'Samarreta',
|
||||||
|
fullName: 'Samarreta Cursa de la Cirera 2026',
|
||||||
|
price: 10.0,
|
||||||
|
image: '/assets/Samarreta.jpeg',
|
||||||
|
features: ['Teixit tècnic Dry Fit', 'Running Mesh Yarn', 'Samarreta Fullprint', 'Mànica recta'],
|
||||||
|
hasTshirt: true,
|
||||||
|
hasSocks: false,
|
||||||
|
badge: null,
|
||||||
|
},
|
||||||
|
mitjons: {
|
||||||
|
id: 'mitjons',
|
||||||
|
name: 'Mitjons',
|
||||||
|
fullName: 'Mitjons Cursa de la Cirera 2026',
|
||||||
|
price: 10.0,
|
||||||
|
image: '/assets/Mitjons.jpeg',
|
||||||
|
features: ['Blood Bros Socks', 'Alta qualitat', 'Disseny exclusiu', 'Talles XXS–XL'],
|
||||||
|
hasTshirt: false,
|
||||||
|
hasSocks: true,
|
||||||
|
badge: null,
|
||||||
|
},
|
||||||
|
pack: {
|
||||||
|
id: 'pack',
|
||||||
|
name: 'Pack Complet',
|
||||||
|
fullName: 'Pack Samarreta + Mitjons',
|
||||||
|
price: 18.5,
|
||||||
|
image: '/assets/Pack Samarreta+Mitjons.jpeg',
|
||||||
|
features: ['Samarreta + Mitjons', 'Estalvia 1,50€', 'Tot l\'equipament', 'Millor preu'],
|
||||||
|
hasTshirt: true,
|
||||||
|
hasSocks: true,
|
||||||
|
badge: 'OFERTA',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type ProductId = keyof typeof PRODUCTS
|
||||||
|
|
||||||
|
const TSHIRT_SIZES = ['S', 'M', 'L', 'XL', 'XXL', '3XL']
|
||||||
|
const SOCK_SIZES = [
|
||||||
|
{ label: 'XXS', range: '32-34' },
|
||||||
|
{ label: 'XS', range: '35-37' },
|
||||||
|
{ label: 'S', range: '38-39' },
|
||||||
|
{ label: 'M', range: '40-42' },
|
||||||
|
{ label: 'L', range: '43-44' },
|
||||||
|
{ label: 'XL', range: '45-47' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROVINCES = [
|
||||||
|
'Lleida', 'Barcelona', 'Girona', 'Tarragona',
|
||||||
|
'Aragó', 'Madrid', 'Valencia', 'Altres',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
nom: string
|
||||||
|
cognoms: string
|
||||||
|
telefon: string
|
||||||
|
email: string
|
||||||
|
adreca: string
|
||||||
|
codiPostal: string
|
||||||
|
poblacio: string
|
||||||
|
provincia: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormData = {
|
||||||
|
nom: '', cognoms: '', telefon: '', email: '',
|
||||||
|
adreca: '', codiPostal: '', poblacio: '', provincia: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cancelled Banner ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CancelledBanner() {
|
||||||
|
const params = useSearchParams()
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.get('cancelled') === 'true') setVisible(true)
|
||||||
|
}, [params])
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 mt-6">
|
||||||
|
<div className="flex items-start gap-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4">
|
||||||
|
<AlertCircle className="text-yellow-400 shrink-0 mt-0.5" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-300 font-semibold text-sm">Pagament cancel·lat</p>
|
||||||
|
<p className="text-yellow-400/70 text-xs mt-0.5">
|
||||||
|
Has cancel·lat el pagament. Torna a omplir el formulari quan vulguis per completar la teva comanda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
className="ml-auto text-yellow-500 hover:text-yellow-300 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<ProductId | null>(null)
|
||||||
|
const [sizeTshirt, setSizeTshirt] = useState('')
|
||||||
|
const [sizeSocks, setSizeSocks] = useState('')
|
||||||
|
const [formData, setFormData] = useState<FormData>(EMPTY_FORM)
|
||||||
|
const [shipping, setShipping] = useState<'correos' | 'recollida' | null>(null)
|
||||||
|
const [sizeGuide, setSizeGuide] = useState<'samarreta' | 'mitjons' | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof FormData | 'product' | 'sizes' | 'shipping', string>>>({})
|
||||||
|
|
||||||
|
const sizesRef = useRef<HTMLDivElement>(null)
|
||||||
|
const formRef = useRef<HTMLDivElement>(null)
|
||||||
|
const shippingRef = useRef<HTMLDivElement>(null)
|
||||||
|
const summaryRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const product = selectedProduct ? PRODUCTS[selectedProduct] : null
|
||||||
|
const shippingCost = shipping === 'correos' ? 7.99 : 0
|
||||||
|
const total = product ? product.price + shippingCost : 0
|
||||||
|
|
||||||
|
function scrollTo(ref: React.RefObject<HTMLDivElement>) {
|
||||||
|
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectProduct(id: ProductId) {
|
||||||
|
setSelectedProduct(id)
|
||||||
|
setSizeTshirt('')
|
||||||
|
setSizeSocks('')
|
||||||
|
setShipping(null)
|
||||||
|
setErrors({})
|
||||||
|
scrollTo(sizesRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeTshirt(s: string) {
|
||||||
|
setSizeTshirt(s)
|
||||||
|
if (!product?.hasSocks) scrollTo(formRef)
|
||||||
|
else if (sizeSocks) scrollTo(formRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeSocks(s: string) {
|
||||||
|
setSizeSocks(s)
|
||||||
|
if (!product?.hasTshirt) scrollTo(formRef)
|
||||||
|
else if (sizeTshirt) scrollTo(formRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleField(key: keyof FormData, value: string) {
|
||||||
|
setFormData(prev => ({ ...prev, [key]: value }))
|
||||||
|
if (errors[key]) setErrors(prev => ({ ...prev, [key]: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShipping(v: 'correos' | 'recollida') {
|
||||||
|
setShipping(v)
|
||||||
|
scrollTo(summaryRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const newErrors: typeof errors = {}
|
||||||
|
if (!selectedProduct) newErrors.product = 'Selecciona un producte'
|
||||||
|
if (product?.hasTshirt && !sizeTshirt) newErrors.sizes = 'Selecciona la talla de la samarreta'
|
||||||
|
if (product?.hasSocks && !sizeSocks) newErrors.sizes = (newErrors.sizes ? newErrors.sizes + ' i els ' : '') + 'Selecciona la talla dels mitjons'
|
||||||
|
if (!formData.nom.trim()) newErrors.nom = 'El nom és obligatori'
|
||||||
|
if (!formData.cognoms.trim()) newErrors.cognoms = 'Els cognoms són obligatoris'
|
||||||
|
if (!formData.telefon.trim()) newErrors.telefon = 'El telèfon és obligatori'
|
||||||
|
if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email))
|
||||||
|
newErrors.email = 'Introdueix un email vàlid'
|
||||||
|
if (!shipping) newErrors.shipping = 'Selecciona una opció de lliurament'
|
||||||
|
if (shipping === 'correos') {
|
||||||
|
if (!formData.adreca.trim()) newErrors.adreca = "L'adreça és obligatòria per a enviament"
|
||||||
|
if (!formData.codiPostal.trim()) newErrors.codiPostal = 'El codi postal és obligatori'
|
||||||
|
if (!formData.poblacio.trim()) newErrors.poblacio = 'La població és obligatòria'
|
||||||
|
if (!formData.provincia.trim()) newErrors.provincia = 'La província és obligatòria'
|
||||||
|
}
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePay() {
|
||||||
|
if (!validate()) {
|
||||||
|
const firstError = document.querySelector('[data-error]')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
product: selectedProduct,
|
||||||
|
sizeTshirt: sizeTshirt || null,
|
||||||
|
sizeSocks: sizeSocks || null,
|
||||||
|
shipping,
|
||||||
|
...formData,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? 'Error desconegut')
|
||||||
|
window.location.href = data.url
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Error inesperat. Torna-ho a intentar.'
|
||||||
|
setErrors({ product: message })
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsTshirtSize = product?.hasTshirt ?? false
|
||||||
|
const needsSockSize = product?.hasSocks ?? false
|
||||||
|
const sizesComplete = (!needsTshirtSize || sizeTshirt) && (!needsSockSize || sizeSocks)
|
||||||
|
const formComplete =
|
||||||
|
formData.nom && formData.cognoms && formData.telefon && formData.email &&
|
||||||
|
(shipping !== 'correos' || (formData.adreca && formData.codiPostal && formData.poblacio && formData.provincia))
|
||||||
|
const canPay = selectedProduct && sizesComplete && formComplete && shipping
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<CountdownBanner />
|
||||||
|
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CancelledBanner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* ─── Hero ─────────────────────────────────────────────── */}
|
||||||
|
<section className="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse 80% 60% at 70% 40%, rgba(0,200,220,0.07) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 30% 60%, rgba(232,64,64,0.05) 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-12 sm:py-20 grid lg:grid-cols-2 gap-10 items-center">
|
||||||
|
{/* Text side */}
|
||||||
|
<div className="order-2 lg:order-1">
|
||||||
|
<p className="section-title">Blood Bros Sport presenta</p>
|
||||||
|
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-black text-white leading-none mb-4">
|
||||||
|
Cursa<br />
|
||||||
|
<span className="text-teal">de la</span><br />
|
||||||
|
Cirera
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl sm:text-2xl font-black text-slate-400 mb-6">
|
||||||
|
Corbins 2026
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-500 text-base mb-8 max-w-md leading-relaxed">
|
||||||
|
L'equipament oficial de la Cursa. Samarreta tècnica Fullprint i mitjons exclusius Blood Bros Socks.
|
||||||
|
Edició limitada. Comandes fins al 25 de juny.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => document.getElementById('productes')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
|
className="btn-pay w-auto px-8 py-3 text-base"
|
||||||
|
>
|
||||||
|
Compra ara
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-dark-card border border-dark-border rounded-xl">
|
||||||
|
<Check size={16} className="text-teal" />
|
||||||
|
<span className="text-sm text-slate-400">Pagament segur via Stripe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image side */}
|
||||||
|
<div className="order-1 lg:order-2 flex justify-center">
|
||||||
|
<div className="relative w-72 sm:w-96">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-3xl blur-3xl opacity-30"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #00C8DC, #E84040)' }}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/assets/Pack Samarreta+Mitjons.jpeg"
|
||||||
|
alt="Pack Samarreta + Mitjons Cursa de la Cirera 2026"
|
||||||
|
width={420}
|
||||||
|
height={560}
|
||||||
|
className="relative z-10 float-anim object-contain w-full"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── STEP 1: PRODUCTES ────────────────────────────────── */}
|
||||||
|
<section id="productes" className="max-w-6xl mx-auto px-4 sm:px-6 pb-16">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="section-title">Pas 1 de 4 · Producte</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
Quin equipament vols?
|
||||||
|
</h2>
|
||||||
|
{errors.product && (
|
||||||
|
<p data-error className="text-cherry text-sm mt-2 flex items-center gap-1">
|
||||||
|
<AlertCircle size={14} /> {errors.product}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-5">
|
||||||
|
{(Object.values(PRODUCTS) as typeof PRODUCTS[keyof typeof PRODUCTS][]).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleSelectProduct(p.id as ProductId)}
|
||||||
|
className={clsx('product-card text-left', { selected: selectedProduct === p.id })}
|
||||||
|
>
|
||||||
|
{p.badge && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className="badge badge-red">{p.badge}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative mb-4 rounded-xl overflow-hidden bg-dark aspect-square">
|
||||||
|
<Image
|
||||||
|
src={p.image}
|
||||||
|
alt={p.fullName}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
sizes="(max-width: 640px) 90vw, 30vw"
|
||||||
|
/>
|
||||||
|
{selectedProduct === p.id && (
|
||||||
|
<div className="absolute inset-0 bg-teal/10 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-teal flex items-center justify-center">
|
||||||
|
<Check size={20} className="text-dark" style={{ color: '#000' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-black text-lg mb-1">{p.name}</h3>
|
||||||
|
<div className="text-2xl font-black text-teal mb-3">
|
||||||
|
{p.price.toFixed(2).replace('.', ',')}€
|
||||||
|
</div>
|
||||||
|
{p.id === 'pack' && (
|
||||||
|
<p className="text-slate-500 text-xs mb-2 line-through">vs 20,00€ per separat</p>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{p.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2 text-slate-500 text-xs">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-teal shrink-0" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── STEP 2: TALLES ───────────────────────────────────── */}
|
||||||
|
<div ref={sizesRef} />
|
||||||
|
{selectedProduct && (
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-16 animate-fade-up">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="section-title">Pas 2 de 4 · Talles</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
La teva talla
|
||||||
|
</h2>
|
||||||
|
{errors.sizes && (
|
||||||
|
<p data-error className="text-cherry text-sm mt-2 flex items-center gap-1">
|
||||||
|
<AlertCircle size={14} /> {errors.sizes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx('grid gap-8', product?.hasTshirt && product?.hasSocks ? 'sm:grid-cols-2' : 'sm:grid-cols-1 max-w-md')}>
|
||||||
|
{/* Tshirt sizes */}
|
||||||
|
{product?.hasTshirt && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-white font-bold">Samarreta</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSizeGuide('samarreta')}
|
||||||
|
className="flex items-center gap-1.5 text-teal text-xs hover:underline"
|
||||||
|
>
|
||||||
|
<Ruler size={14} />
|
||||||
|
Guia de talles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TSHIRT_SIZES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => handleSizeTshirt(s)}
|
||||||
|
className={clsx('size-btn', { selected: sizeTshirt === s })}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sizeTshirt && (
|
||||||
|
<p className="text-teal text-xs mt-3 flex items-center gap-1">
|
||||||
|
<Check size={12} /> Talla {sizeTshirt} seleccionada
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Socks sizes */}
|
||||||
|
{product?.hasSocks && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-white font-bold">Mitjons</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSizeGuide('mitjons')}
|
||||||
|
className="flex items-center gap-1.5 text-teal text-xs hover:underline"
|
||||||
|
>
|
||||||
|
<Ruler size={14} />
|
||||||
|
Guia de talles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SOCK_SIZES.map(({ label, range }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => handleSizeSocks(label)}
|
||||||
|
className={clsx('size-btn flex flex-col items-center', { selected: sizeSocks === label })}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-[10px] opacity-60">{range}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sizeSocks && (
|
||||||
|
<p className="text-teal text-xs mt-3 flex items-center gap-1">
|
||||||
|
<Check size={12} /> Talla {sizeSocks} seleccionada
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── STEP 3: DADES ────────────────────────────────────── */}
|
||||||
|
<div ref={formRef} />
|
||||||
|
{selectedProduct && sizesComplete && (
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-16 animate-fade-up">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="section-title">Pas 3 de 4 · Les teves dades</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
On t'enviem la comanda?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
{/* Nom */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Nom *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={clsx('input-field', errors.nom && 'border-cherry')}
|
||||||
|
placeholder="El teu nom"
|
||||||
|
value={formData.nom}
|
||||||
|
onChange={(e) => handleField('nom', e.target.value)}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
{errors.nom && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.nom}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cognoms */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Cognoms *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={clsx('input-field', errors.cognoms && 'border-cherry')}
|
||||||
|
placeholder="Els teus cognoms"
|
||||||
|
value={formData.cognoms}
|
||||||
|
onChange={(e) => handleField('cognoms', e.target.value)}
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
{errors.cognoms && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.cognoms}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telèfon */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Telèfon mòbil *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className={clsx('input-field', errors.telefon && 'border-cherry')}
|
||||||
|
placeholder="6XX XXX XXX"
|
||||||
|
value={formData.telefon}
|
||||||
|
onChange={(e) => handleField('telefon', e.target.value)}
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
{errors.telefon && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.telefon}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Correu electrònic *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={clsx('input-field', errors.email && 'border-cherry')}
|
||||||
|
placeholder="tu@exemple.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleField('email', e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adreça */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="form-label">
|
||||||
|
Adreça postal
|
||||||
|
<span className="text-slate-600 normal-case font-normal ml-1">(per enviament a domicili)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={clsx('input-field', errors.adreca && 'border-cherry')}
|
||||||
|
placeholder="Carrer, número, pis..."
|
||||||
|
value={formData.adreca}
|
||||||
|
onChange={(e) => handleField('adreca', e.target.value)}
|
||||||
|
autoComplete="street-address"
|
||||||
|
/>
|
||||||
|
{errors.adreca && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.adreca}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CP */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Codi postal</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={clsx('input-field', errors.codiPostal && 'border-cherry')}
|
||||||
|
placeholder="25XXX"
|
||||||
|
value={formData.codiPostal}
|
||||||
|
onChange={(e) => handleField('codiPostal', e.target.value)}
|
||||||
|
maxLength={5}
|
||||||
|
autoComplete="postal-code"
|
||||||
|
/>
|
||||||
|
{errors.codiPostal && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.codiPostal}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Població */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Població</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={clsx('input-field', errors.poblacio && 'border-cherry')}
|
||||||
|
placeholder="Corbins, Lleida..."
|
||||||
|
value={formData.poblacio}
|
||||||
|
onChange={(e) => handleField('poblacio', e.target.value)}
|
||||||
|
autoComplete="address-level2"
|
||||||
|
/>
|
||||||
|
{errors.poblacio && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.poblacio}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Província */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="form-label">Província</label>
|
||||||
|
<select
|
||||||
|
className={clsx('input-field', errors.provincia && 'border-cherry')}
|
||||||
|
value={formData.provincia}
|
||||||
|
onChange={(e) => handleField('provincia', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Selecciona una província...</option>
|
||||||
|
{PROVINCES.map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.provincia && (
|
||||||
|
<p data-error className="text-cherry text-xs mt-1">{errors.provincia}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formComplete && (
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-teal text-sm">
|
||||||
|
<Check size={16} />
|
||||||
|
<span>Dades correctes. Ara selecciona com vols rebre la comanda.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── STEP 4: ENVIAMENT ────────────────────────────────── */}
|
||||||
|
<div ref={shippingRef} />
|
||||||
|
{selectedProduct && sizesComplete && formData.nom && (
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-16 animate-fade-up">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="section-title">Pas 4 de 4 · Lliurament</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
Com vols rebre-ho?
|
||||||
|
</h2>
|
||||||
|
{errors.shipping && (
|
||||||
|
<p data-error className="text-cherry text-sm mt-2 flex items-center gap-1">
|
||||||
|
<AlertCircle size={14} /> {errors.shipping}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 max-w-2xl">
|
||||||
|
{/* Correos Express */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleShipping('correos')}
|
||||||
|
className={clsx('shipping-card text-left', { selected: shipping === 'correos' })}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="text-2xl">🚚</div>
|
||||||
|
{shipping === 'correos' && (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-teal flex items-center justify-center shrink-0">
|
||||||
|
<Check size={12} style={{ color: '#000' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold mb-1">Correos Express</h3>
|
||||||
|
<p className="text-slate-500 text-sm mb-3">Enviament a casa teva. Lliurament en 24-48h hàbils.</p>
|
||||||
|
<div className="text-cherry font-black text-xl">+7,99€</div>
|
||||||
|
<p className="text-slate-600 text-xs mt-1">Lliurament previst: 29 Juny – 3 Juliol</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Recollida */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleShipping('recollida')}
|
||||||
|
className={clsx('shipping-card text-left', { selected: shipping === 'recollida' })}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="text-2xl">📍</div>
|
||||||
|
{shipping === 'recollida' && (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-teal flex items-center justify-center shrink-0">
|
||||||
|
<Check size={12} style={{ color: '#000' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold mb-1">Recollida Corbins / Lleida</h3>
|
||||||
|
<p className="text-slate-500 text-sm mb-3">Vés a buscar la comanda. Ens posem en contacte amb tu per coordinar.</p>
|
||||||
|
<div className="text-teal font-black text-xl">Gratuït</div>
|
||||||
|
<p className="text-slate-600 text-xs mt-1">Lliurament previst: 29 Juny – 3 Juliol</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── RESUM I PAGAMENT ─────────────────────────────────── */}
|
||||||
|
<div ref={summaryRef} />
|
||||||
|
{selectedProduct && sizesComplete && formData.nom && shipping && (
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20 animate-fade-up">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="section-title">Resum de la comanda</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
Llest per pagar?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
|
||||||
|
{/* Summary items */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-xl overflow-hidden bg-dark shrink-0">
|
||||||
|
<Image
|
||||||
|
src={product!.image}
|
||||||
|
alt={product!.fullName}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-white font-bold text-sm">{product!.fullName}</div>
|
||||||
|
<div className="text-slate-500 text-xs mt-0.5">
|
||||||
|
{sizeTshirt && `Samarreta: ${sizeTshirt}`}
|
||||||
|
{sizeTshirt && sizeSocks && ' · '}
|
||||||
|
{sizeSocks && `Mitjons: ${sizeSocks}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-white font-bold">
|
||||||
|
{product!.price.toFixed(2).replace('.', ',')}€
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{shipping === 'correos' ? 'Correos Express' : 'Recollida Corbins/Lleida'}
|
||||||
|
</span>
|
||||||
|
<span className={shipping === 'correos' ? 'text-white' : 'text-teal'}>
|
||||||
|
{shipping === 'correos' ? '7,99€' : 'Gratuït'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-dark-border pt-4 flex items-center justify-between">
|
||||||
|
<span className="text-white font-black text-lg">TOTAL</span>
|
||||||
|
<span className="text-teal font-black text-3xl">
|
||||||
|
{total.toFixed(2).replace('.', ',')}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client summary */}
|
||||||
|
<div className="border-t border-dark-border px-6 py-4 bg-dark/40">
|
||||||
|
<p className="text-slate-500 text-xs mb-2 uppercase tracking-wider">Comanda per a</p>
|
||||||
|
<p className="text-white text-sm font-semibold">{formData.nom} {formData.cognoms}</p>
|
||||||
|
<p className="text-slate-500 text-xs">{formData.email} · {formData.telefon}</p>
|
||||||
|
{shipping === 'correos' && formData.adreca && (
|
||||||
|
<p className="text-slate-500 text-xs mt-1">
|
||||||
|
{formData.adreca}, {formData.codiPostal} {formData.poblacio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pay button */}
|
||||||
|
<div className="p-6 border-t border-dark-border">
|
||||||
|
<button
|
||||||
|
onClick={handlePay}
|
||||||
|
disabled={!canPay || loading}
|
||||||
|
className="btn-pay"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
Preparant pagament...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Pagar {total.toFixed(2).replace('.', ',')}€ amb Stripe
|
||||||
|
<svg viewBox="0 0 60 25" fill="currentColor" className="h-5 opacity-80">
|
||||||
|
<path d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.37 4.05-.95v3.32a12.08 12.08 0 0 1-4.56.83c-4.06 0-6.8-2.22-6.8-7 0-4.6 2.7-7.1 6.3-7.1 3.54 0 5.96 2.38 5.96 7.35zm-8.12-2.3h4.12c-.1-1.81-.98-2.59-2-2.59-1.01 0-1.99.68-2.12 2.59zM44.9 25l1.59-8.6a13.83 13.83 0 0 1-3.5.48c-3.36 0-4.76-2.28-4.76-6.31 0-4.48 2.45-7.45 6.43-7.45 1.32 0 2.47.23 3.45.65l.6-3.15A17.1 17.1 0 0 0 44.5 0C39.29 0 33.5 3.71 33.5 10.72c0 5.72 2.91 9.06 8.42 9.06a14 14 0 0 0 3.81-.52L44.9 25zm-25.08-3.84c.79 0 1.5-.18 2.12-.54L20.6 25c-.74.28-1.74.48-2.86.48C13.38 25.48 11 22.9 11 18.7c0-5.5 3.1-8.05 7.09-8.05 2.9 0 5.1 1.3 6.1 3.67L22 13.2l.54-2.86H18.3L15.4 25h4.42zm-3-7.03c0 2.14 1.07 3.09 2.76 3.09.4 0 .78-.1 1.12-.26L20.5 14h-.21c-1.82 0-3.47 1-3.47 4.13zM10.04 7.75a3.6 3.6 0 0 1-1.4-.28L7 18.73H2.86L5.9 3h4.32l-.53 2.83c.74-.87 1.83-2.28 3.26-2.83h.3l-1.3 5.31c-.5-.39-1.25-.56-1.91-.56z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-slate-600 text-xs mt-3">
|
||||||
|
Seràs redirigit a Stripe per completar el pagament de forma segura
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TrustSection />
|
||||||
|
|
||||||
|
{/* CTA if nothing selected yet */}
|
||||||
|
{!selectedProduct && (
|
||||||
|
<div className="text-center py-8 pb-16">
|
||||||
|
<button
|
||||||
|
onClick={() => document.getElementById('productes')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
|
className="flex items-center gap-2 mx-auto text-teal hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ChevronDown size={18} className="animate-bounce" />
|
||||||
|
Veure productes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
{/* Size guide modal */}
|
||||||
|
{sizeGuide && (
|
||||||
|
<SizeGuideModal type={sizeGuide} onClose={() => setSizeGuide(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { CheckCircle, Package, Truck, CalendarCheck, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
|
const PRODUCT_NAMES: Record<string, string> = {
|
||||||
|
samarreta: 'Samarreta Cursa de la Cirera 2026',
|
||||||
|
mitjons: 'Mitjons Cursa de la Cirera 2026',
|
||||||
|
pack: 'Pack Samarreta + Mitjons',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderData {
|
||||||
|
orderNumber: string
|
||||||
|
nom: string
|
||||||
|
cognoms: string
|
||||||
|
email: string
|
||||||
|
product: string
|
||||||
|
sizeTshirt?: string
|
||||||
|
sizeSocks?: string
|
||||||
|
shipping: string
|
||||||
|
totalAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessContent() {
|
||||||
|
const params = useSearchParams()
|
||||||
|
const sessionId = params.get('session_id')
|
||||||
|
const [order, setOrder] = useState<OrderData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) { setLoading(false); return }
|
||||||
|
|
||||||
|
// Poll a bit to give webhook time to process
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 8
|
||||||
|
|
||||||
|
async function fetchOrder() {
|
||||||
|
attempts++
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/by-session?session_id=${sessionId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.order) {
|
||||||
|
setOrder(data.order)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore, will retry
|
||||||
|
}
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(fetchOrder, 1500)
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOrder()
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-teal border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-400">Confirmant el teu pagament...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-dark-border bg-dark/90 py-3">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex items-center gap-3">
|
||||||
|
<Image
|
||||||
|
src="/assets/LOGO BLOD BROS SPORT GOTA RODONA ALTA RES.jpg"
|
||||||
|
alt="Blood Bros Sport"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-white font-bold text-sm">Blood Bros Sport</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 flex items-center justify-center py-12 px-4">
|
||||||
|
<div className="max-w-lg w-full text-center">
|
||||||
|
{/* Success icon */}
|
||||||
|
<div className="relative inline-flex mb-6">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full blur-2xl opacity-30 scale-150"
|
||||||
|
style={{ background: 'radial-gradient(circle, #00C8DC, transparent)' }}
|
||||||
|
/>
|
||||||
|
<CheckCircle size={80} className="relative text-teal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-black text-white mb-3">
|
||||||
|
Comanda<br />
|
||||||
|
<span className="text-teal">confirmada!</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-slate-400 text-base mb-8">
|
||||||
|
{order
|
||||||
|
? `Gràcies ${order.nom}! Hem rebut la teva comanda i en breu rebràs la confirmació per email a ${order.email}.`
|
||||||
|
: 'El teu pagament ha estat processat correctament. Rebràs la confirmació per email en breu.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{order && (
|
||||||
|
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden mb-8 text-left">
|
||||||
|
{/* Order number */}
|
||||||
|
<div className="bg-teal/10 border-b border-dark-border p-5 text-center">
|
||||||
|
<p className="text-slate-500 text-xs uppercase tracking-widest mb-1">Número de comanda</p>
|
||||||
|
<p className="text-teal text-3xl font-black tracking-wider">{order.orderNumber}</p>
|
||||||
|
<p className="text-slate-600 text-xs mt-1">Guarda'l per a consultes futures</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order details */}
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-500">Producte</span>
|
||||||
|
<span className="text-white font-semibold">{PRODUCT_NAMES[order.product] ?? order.product}</span>
|
||||||
|
</div>
|
||||||
|
{(order.sizeTshirt || order.sizeSocks) && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-500">Talles</span>
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{[
|
||||||
|
order.sizeTshirt && `Samarreta ${order.sizeTshirt}`,
|
||||||
|
order.sizeSocks && `Mitjons ${order.sizeSocks}`,
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-500">Lliurament</span>
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{order.shipping === 'correos' ? 'Correos Express' : 'Recollida Corbins/Lleida'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-dark-border pt-3 flex justify-between">
|
||||||
|
<span className="text-white font-black">TOTAL</span>
|
||||||
|
<span className="text-teal font-black text-xl">
|
||||||
|
{order.totalAmount.toFixed(2).replace('.', ',')}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="flex flex-col gap-3 mb-8 text-left">
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-dark-card border border-teal/30 rounded-xl">
|
||||||
|
<CheckCircle size={20} className="text-teal shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold text-sm">Pagament confirmat</p>
|
||||||
|
<p className="text-slate-500 text-xs">El teu pagament s'ha processat correctament via Stripe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-dark-card border border-dark-border rounded-xl">
|
||||||
|
<Package size={20} className="text-slate-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 font-semibold text-sm">Preparació de la comanda</p>
|
||||||
|
<p className="text-slate-600 text-xs">A partir del 25 de juny, quan es tanquin les comandes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-dark-card border border-dark-border rounded-xl">
|
||||||
|
<Truck size={20} className="text-slate-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 font-semibold text-sm">Enviament / Recollida</p>
|
||||||
|
<p className="text-slate-600 text-xs">Rebràs una notificació quan la teva comanda surti.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-dark-card border border-dark-border rounded-xl">
|
||||||
|
<CalendarCheck size={20} className="text-slate-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 font-semibold text-sm">Lliurament previst: 29 Juny – 3 Juliol 2026</p>
|
||||||
|
<p className="text-slate-600 text-xs">Amb temps per a la Cursa de la Cirera!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-teal hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Tornar a la botiga
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-dark-border py-4 text-center">
|
||||||
|
<p className="text-slate-700 text-xs">
|
||||||
|
© 2026 Blood Bros Sport · Cursa de la Cirera · Corbins, Lleida
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SuccessPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-teal border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SuccessContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const ORDER_DEADLINE = new Date('2026-06-25T23:59:59')
|
||||||
|
|
||||||
|
function getTimeLeft() {
|
||||||
|
const diff = ORDER_DEADLINE.getTime() - Date.now()
|
||||||
|
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }
|
||||||
|
return {
|
||||||
|
days: Math.floor(diff / 86400000),
|
||||||
|
hours: Math.floor((diff % 86400000) / 3600000),
|
||||||
|
minutes: Math.floor((diff % 3600000) / 60000),
|
||||||
|
seconds: Math.floor((diff % 60000) / 1000),
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Digit({ value, label }: { value: number; label: string }) {
|
||||||
|
const [prev, setPrev] = useState(value)
|
||||||
|
const [flipping, setFlipping] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== prev) {
|
||||||
|
setFlipping(true)
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPrev(value)
|
||||||
|
setFlipping(false)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [value, prev])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="countdown-digit">
|
||||||
|
<span
|
||||||
|
className="number"
|
||||||
|
style={{
|
||||||
|
animation: flipping ? 'countdownFlip 0.3s ease-out' : 'none',
|
||||||
|
color: label === 'dies' && value <= 3 ? '#E84040' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(value).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="label">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CountdownBanner() {
|
||||||
|
const [time, setTime] = useState(getTimeLeft)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setTime(getTimeLeft()), 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-r from-dark via-dark-card to-dark border-b border-dark-border">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 text-center">
|
||||||
|
{time.expired ? (
|
||||||
|
<div className="text-cherry font-black text-xl uppercase tracking-widest">
|
||||||
|
Periode de comandes tancat
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 mb-4">
|
||||||
|
Comandes obertes fins el 25 de juny a mitjanit
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3 sm:gap-4">
|
||||||
|
<Digit value={time.days} label="dies" />
|
||||||
|
<span className="text-3xl font-black text-dark-muted mb-4">:</span>
|
||||||
|
<Digit value={time.hours} label="hores" />
|
||||||
|
<span className="text-3xl font-black text-dark-muted mb-4">:</span>
|
||||||
|
<Digit value={time.minutes} label="min" />
|
||||||
|
<span className="text-3xl font-black text-dark-muted mb-4">:</span>
|
||||||
|
<Digit value={time.seconds} label="seg" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-4 text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-teal inline-block" />
|
||||||
|
Comandes: 18 – 25 Juny 2026
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-cherry inline-block" />
|
||||||
|
Lliurament: 29 Juny – 3 Juliol 2026
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
import { Instagram, Globe } from 'lucide-react'
|
||||||
|
|
||||||
|
const SPONSORS = [
|
||||||
|
{
|
||||||
|
name: 'Finques Prats',
|
||||||
|
role: 'Patrocinador principal',
|
||||||
|
instagram: 'https://www.instagram.com/finquesprats/',
|
||||||
|
web: null,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Runners Corbins',
|
||||||
|
role: 'Partner',
|
||||||
|
instagram: 'https://www.instagram.com/runnerscorbins/',
|
||||||
|
web: null,
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ajuntament de Corbins',
|
||||||
|
role: 'Partner',
|
||||||
|
instagram: 'https://www.instagram.com/viu_corbins/',
|
||||||
|
web: 'https://www.corbins.cat',
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-dark-border bg-dark">
|
||||||
|
{/* Sponsors */}
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-600">
|
||||||
|
Amb el suport de
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-6 sm:gap-10">
|
||||||
|
{SPONSORS.map((s) => (
|
||||||
|
<div key={s.name} className="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-5 py-3 rounded-xl border transition-colors
|
||||||
|
${s.highlight
|
||||||
|
? 'border-teal/30 bg-teal/5 hover:bg-teal/10'
|
||||||
|
: 'border-dark-border bg-dark-card hover:border-dark-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`font-bold text-sm ${s.highlight ? 'text-white' : 'text-slate-400'}`}>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
{s.highlight && (
|
||||||
|
<span className="badge badge-teal text-[10px]">Patrocinador</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{s.instagram && (
|
||||||
|
<a
|
||||||
|
href={s.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-slate-600 hover:text-teal transition-colors"
|
||||||
|
aria-label={`Instagram de ${s.name}`}
|
||||||
|
>
|
||||||
|
<Instagram size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{s.web && (
|
||||||
|
<a
|
||||||
|
href={s.web}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-slate-600 hover:text-teal transition-colors"
|
||||||
|
aria-label={`Web de ${s.name}`}
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="border-t border-dark-border/50 py-6">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-center sm:text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/assets/LOGO BLOD BROS SPORT GOTA RODONA ALTA RES.jpg"
|
||||||
|
alt="Blood Bros Sport"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-600 text-xs">
|
||||||
|
© 2026 Blood Bros Sport · Tots els drets reservats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-700">
|
||||||
|
<span>Pagament segur via Stripe</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Cursa de la Cirera · Corbins, Lleida</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="w-full border-b border-dark-border bg-dark/90 backdrop-blur-md sticky top-0 z-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image
|
||||||
|
src="/assets/LOGO BLOD BROS SPORT GOTA RODONA ALTA RES.jpg"
|
||||||
|
alt="Blood Bros Sport"
|
||||||
|
width={52}
|
||||||
|
height={52}
|
||||||
|
className="rounded-full"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-widest leading-none">
|
||||||
|
Blood Bros Sport
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-black text-white leading-tight">
|
||||||
|
Cursa de la Cirera 2026
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500 hidden sm:block">Comandes fins al</span>
|
||||||
|
<span className="badge badge-red text-xs">25 Juny</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: 'samarreta' | 'mitjons'
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SizeGuideModal({ type, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative bg-dark-card border border-dark-border rounded-2xl max-w-2xl w-full overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-dark-border">
|
||||||
|
<h3 className="text-white font-bold text-lg">
|
||||||
|
Guia de talles – {type === 'samarreta' ? 'Samarreta' : 'Mitjons'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white transition-colors rounded-lg p-1 hover:bg-dark-muted"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<Image
|
||||||
|
src={type === 'samarreta' ? '/assets/Talles Samarreta.jpeg' : '/assets/Talles Mitjons.jpeg'}
|
||||||
|
alt={`Guia de talles ${type}`}
|
||||||
|
width={640}
|
||||||
|
height={400}
|
||||||
|
className="w-full rounded-xl object-contain"
|
||||||
|
/>
|
||||||
|
{type === 'samarreta' && (
|
||||||
|
<div className="mt-4 grid grid-cols-3 sm:grid-cols-6 gap-2 text-center text-xs">
|
||||||
|
{[
|
||||||
|
{ size: 'S', h: 67, w: 50 },
|
||||||
|
{ size: 'M', h: 70, w: 52 },
|
||||||
|
{ size: 'L', h: 73, w: 55 },
|
||||||
|
{ size: 'XL', h: 76, w: 58 },
|
||||||
|
{ size: 'XXL', h: 79, w: 61 },
|
||||||
|
{ size: '3XL', h: 82, w: 64 },
|
||||||
|
].map(({ size, h, w }) => (
|
||||||
|
<div key={size} className="bg-dark border border-dark-border rounded-lg p-2">
|
||||||
|
<div className="text-teal font-black mb-1">{size}</div>
|
||||||
|
<div className="text-slate-500">{h}cm alt</div>
|
||||||
|
<div className="text-slate-500">{w}cm ample</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'mitjons' && (
|
||||||
|
<div className="mt-4 grid grid-cols-3 sm:grid-cols-6 gap-2 text-center text-xs">
|
||||||
|
{[
|
||||||
|
{ size: 'XXS', range: '32-34' },
|
||||||
|
{ size: 'XS', range: '35-37' },
|
||||||
|
{ size: 'S', range: '38-39' },
|
||||||
|
{ size: 'M', range: '40-42' },
|
||||||
|
{ size: 'L', range: '43-44' },
|
||||||
|
{ size: 'XL', range: '45-47' },
|
||||||
|
].map(({ size, range }) => (
|
||||||
|
<div key={size} className="bg-dark border border-dark-border rounded-lg p-2">
|
||||||
|
<div className="text-teal font-black mb-1">{size}</div>
|
||||||
|
<div className="text-slate-500">{range}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ShieldCheck, Truck, CalendarCheck, Package } from 'lucide-react'
|
||||||
|
|
||||||
|
const BADGES = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: 'Pagament 100% segur',
|
||||||
|
desc: 'Processat per Stripe. Les teves dades bancàries mai passen pels nostres servidors.',
|
||||||
|
color: 'text-teal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Package,
|
||||||
|
title: 'Producte de qualitat',
|
||||||
|
desc: "Samarreta Fullprint Dry Fit i mitjons Blood Bros Socks. Producte d'alt rendiment.",
|
||||||
|
color: 'text-teal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Truck,
|
||||||
|
title: 'Lliurament garantit',
|
||||||
|
desc: "Enviament Correos Express o recollida a Corbins/Lleida. Compromís d'entrega.",
|
||||||
|
color: 'text-cherry',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CalendarCheck,
|
||||||
|
title: 'Entrega 29 Juny – 3 Juliol',
|
||||||
|
desc: 'Tens el teu equipament a temps per a la Cursa de la Cirera 2026.',
|
||||||
|
color: 'text-cherry',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TrustSection() {
|
||||||
|
return (
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 py-16">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<p className="section-title">Per què comprar amb nosaltres</p>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white">
|
||||||
|
Som seriosos.{' '}
|
||||||
|
<span className="text-teal">Ho lliurem.</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{BADGES.map(({ icon: Icon, title, desc, color }) => (
|
||||||
|
<div key={title} className="trust-card">
|
||||||
|
<div className={`${color} mb-1`}>
|
||||||
|
<Icon size={32} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-sm">{title}</h3>
|
||||||
|
<p className="text-slate-500 text-xs leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import type { Order } from '@prisma/client'
|
||||||
|
|
||||||
|
function createTransport() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT ?? 587),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_NAMES: Record<string, string> = {
|
||||||
|
samarreta: 'Samarreta Cursa de la Cirera 2026',
|
||||||
|
mitjons: 'Mitjons Cursa de la Cirera 2026',
|
||||||
|
pack: 'Pack Samarreta + Mitjons Cursa de la Cirera 2026',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Pendent',
|
||||||
|
PAID: 'Pagat',
|
||||||
|
PREPARING: 'En preparació',
|
||||||
|
SHIPPED: 'Enviat',
|
||||||
|
DELIVERED: 'Lliurat',
|
||||||
|
CANCELLED: 'Cancel·lat',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSizes(order: Order): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (order.sizeTshirt) parts.push(`Samarreta: ${order.sizeTshirt}`)
|
||||||
|
if (order.sizeSocks) parts.push(`Mitjons: ${order.sizeSocks}`)
|
||||||
|
return parts.join(' · ') || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShipping(order: Order): string {
|
||||||
|
if (order.shipping === 'correos') {
|
||||||
|
return `Correos Express (+${order.shippingAmount.toFixed(2).replace('.', ',')}€)`
|
||||||
|
}
|
||||||
|
return 'Recollida a Corbins/Lleida (gratuït)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseEmailHtml(content: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ca">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #070714; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background: #0E0E1F; }
|
||||||
|
.header { background: linear-gradient(135deg, #0E0E1F 0%, #1C1C38 100%); padding: 32px 40px; text-align: center; border-bottom: 2px solid #00C8DC; }
|
||||||
|
.header h1 { color: #00C8DC; font-size: 14px; letter-spacing: 3px; text-transform: uppercase; margin: 0 0 4px; }
|
||||||
|
.header h2 { color: #FFFFFF; font-size: 22px; font-weight: 800; margin: 0; }
|
||||||
|
.body { padding: 32px 40px; }
|
||||||
|
.order-box { background: #1C1C38; border-radius: 12px; padding: 24px; margin: 20px 0; }
|
||||||
|
.order-number { color: #00C8DC; font-size: 24px; font-weight: 800; letter-spacing: 2px; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #2A2A45; }
|
||||||
|
.row:last-child { border-bottom: none; }
|
||||||
|
.row .label { color: #8888AA; font-size: 14px; }
|
||||||
|
.row .value { color: #FFFFFF; font-size: 14px; font-weight: 600; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; padding: 16px 0 0; margin-top: 8px; }
|
||||||
|
.total-row .label { color: #FFFFFF; font-size: 16px; font-weight: 700; }
|
||||||
|
.total-row .value { color: #00C8DC; font-size: 20px; font-weight: 800; }
|
||||||
|
.info-box { background: #1C1C38; border-left: 3px solid #00C8DC; border-radius: 0 8px 8px 0; padding: 16px 20px; margin: 20px 0; }
|
||||||
|
.info-box p { color: #CCCCDD; font-size: 14px; margin: 0 0 8px; }
|
||||||
|
.info-box p:last-child { margin: 0; }
|
||||||
|
.footer { background: #070714; padding: 24px 40px; text-align: center; border-top: 1px solid #1C1C38; }
|
||||||
|
.footer p { color: #555577; font-size: 12px; margin: 4px 0; }
|
||||||
|
h3 { color: #FFFFFF; font-size: 16px; margin: 24px 0 12px; }
|
||||||
|
.address { color: #CCCCDD; font-size: 14px; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
${content}
|
||||||
|
<div class="footer">
|
||||||
|
<p>Blood Bros Sport · Cursa de la Cirera 2026 · Corbins, Lleida</p>
|
||||||
|
<p>Patrocinador principal: Finques Prats</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendOrderConfirmationToClient(order: Order) {
|
||||||
|
if (!process.env.SMTP_HOST) return
|
||||||
|
|
||||||
|
const transporter = createTransport()
|
||||||
|
const productName = PRODUCT_NAMES[order.product] ?? order.product
|
||||||
|
const sizes = formatSizes(order)
|
||||||
|
const shippingText = formatShipping(order)
|
||||||
|
|
||||||
|
const addressBlock =
|
||||||
|
order.shipping === 'correos'
|
||||||
|
? `<div class="info-box"><p><strong>Adreça d'enviament:</strong></p><p class="address">${order.nom} ${order.cognoms}<br>${order.adreca}<br>${order.codiPostal} ${order.poblacio} (${order.provincia})</p></div>`
|
||||||
|
: `<div class="info-box"><p><strong>Punt de recollida:</strong> Corbins / Lleida</p><p>Ens posarem en contacte amb tu per coordinar l'entrega.</p></div>`
|
||||||
|
|
||||||
|
const html = baseEmailHtml(`
|
||||||
|
<div class="header">
|
||||||
|
<h1>Blood Bros Sport</h1>
|
||||||
|
<h2>Confirmació de comanda</h2>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p style="color:#CCCCDD; font-size:16px; margin:0 0 8px;">Hola ${order.nom},</p>
|
||||||
|
<p style="color:#8888AA; font-size:14px; margin:0 0 24px;">Hem rebut la teva comanda. Aquí tens el resum:</p>
|
||||||
|
|
||||||
|
<div class="order-box">
|
||||||
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
|
<div style="color:#8888AA; font-size:12px; letter-spacing:2px; text-transform:uppercase;">Número de comanda</div>
|
||||||
|
<div class="order-number">${order.orderNumber}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row"><span class="label">Producte</span><span class="value">${productName}</span></div>
|
||||||
|
<div class="row"><span class="label">Talles</span><span class="value">${sizes}</span></div>
|
||||||
|
<div class="row"><span class="label">Lliurament</span><span class="value">${shippingText}</span></div>
|
||||||
|
<div class="row"><span class="label">Producte</span><span class="value">${order.baseAmount.toFixed(2).replace('.', ',')}€</span></div>
|
||||||
|
${order.shippingAmount > 0 ? `<div class="row"><span class="label">Enviament</span><span class="value">${order.shippingAmount.toFixed(2).replace('.', ',')}€</span></div>` : ''}
|
||||||
|
<div class="total-row"><span class="label">Total pagat</span><span class="value">${order.totalAmount.toFixed(2).replace('.', ',')}€</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${addressBlock}
|
||||||
|
|
||||||
|
<div class="info-box" style="border-color:#E84040;">
|
||||||
|
<p><strong style="color:#E84040;">Calendari de lliurament</strong></p>
|
||||||
|
<p>Periode de comandes: 18 – 25 de Juny 2026</p>
|
||||||
|
<p>Lliurament previst: 29 de Juny – 3 de Juliol 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Necessites ajuda?</h3>
|
||||||
|
<p style="color:#8888AA; font-size:14px;">Si tens qualsevol dubte sobre la teva comanda ${order.orderNumber}, contacta'ns. Estem aquí per ajudar-te.</p>
|
||||||
|
<p style="color:#8888AA; font-size:14px; margin-top:24px;">Moltes gràcies per la teva confiança!<br><strong style="color:#FFFFFF;">L'equip de Blood Bros Sport</strong></p>
|
||||||
|
</div>`)
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Blood Bros Sport" <${process.env.SMTP_FROM ?? process.env.SMTP_USER}>`,
|
||||||
|
to: order.email,
|
||||||
|
subject: `Confirmació comanda ${order.orderNumber} – Cursa de la Cirera 2026`,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNewOrderNotificationToAdmin(order: Order) {
|
||||||
|
if (!process.env.SMTP_HOST || !process.env.ADMIN_EMAIL) return
|
||||||
|
|
||||||
|
const transporter = createTransport()
|
||||||
|
const productName = PRODUCT_NAMES[order.product] ?? order.product
|
||||||
|
const sizes = formatSizes(order)
|
||||||
|
const shippingText = formatShipping(order)
|
||||||
|
|
||||||
|
const addressBlock =
|
||||||
|
order.shipping === 'correos'
|
||||||
|
? `<div class="row"><span class="label">Adreça</span><span class="value" style="text-align:right;">${order.adreca}<br>${order.codiPostal} ${order.poblacio}<br>${order.provincia}</span></div>`
|
||||||
|
: `<div class="row"><span class="label">Recollida a</span><span class="value">Corbins / Lleida</span></div>`
|
||||||
|
|
||||||
|
const html = baseEmailHtml(`
|
||||||
|
<div class="header">
|
||||||
|
<h1>Nova comanda rebuda</h1>
|
||||||
|
<h2>${order.orderNumber}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="order-box">
|
||||||
|
<div class="row"><span class="label">Producte</span><span class="value">${productName}</span></div>
|
||||||
|
<div class="row"><span class="label">Talles</span><span class="value">${sizes}</span></div>
|
||||||
|
<div class="row"><span class="label">Lliurament</span><span class="value">${shippingText}</span></div>
|
||||||
|
<div class="total-row"><span class="label">TOTAL</span><span class="value">${order.totalAmount.toFixed(2).replace('.', ',')}€</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Dades del client</h3>
|
||||||
|
<div class="order-box">
|
||||||
|
<div class="row"><span class="label">Nom</span><span class="value">${order.nom} ${order.cognoms}</span></div>
|
||||||
|
<div class="row"><span class="label">Telèfon</span><span class="value">${order.telefon}</span></div>
|
||||||
|
<div class="row"><span class="label">Email</span><span class="value">${order.email}</span></div>
|
||||||
|
${addressBlock}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin-top:24px;">
|
||||||
|
<a href="${process.env.NEXT_PUBLIC_BASE_URL}/admin" style="display:inline-block; background:#00C8DC; color:#000; padding:12px 32px; border-radius:8px; text-decoration:none; font-weight:700; font-size:14px; letter-spacing:1px; text-transform:uppercase;">Veure al panel d'admin</a>
|
||||||
|
</div>
|
||||||
|
</div>`)
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Blood Bros Sport" <${process.env.SMTP_FROM ?? process.env.SMTP_USER}>`,
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `[NOVA COMANDA] ${order.orderNumber} – ${productName} – ${order.nom} ${order.cognoms}`,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendStatusUpdateToClient(order: Order) {
|
||||||
|
if (!process.env.SMTP_HOST) return
|
||||||
|
|
||||||
|
const transporter = createTransport()
|
||||||
|
const statusLabel = STATUS_LABELS[order.status] ?? order.status
|
||||||
|
|
||||||
|
const html = baseEmailHtml(`
|
||||||
|
<div class="header">
|
||||||
|
<h1>Actualització de comanda</h1>
|
||||||
|
<h2>${order.orderNumber}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p style="color:#CCCCDD; font-size:16px; margin:0 0 8px;">Hola ${order.nom},</p>
|
||||||
|
<p style="color:#8888AA; font-size:14px; margin:0 0 24px;">Hi ha una actualització en la teva comanda:</p>
|
||||||
|
|
||||||
|
<div class="order-box" style="text-align:center;">
|
||||||
|
<div style="color:#8888AA; font-size:12px; letter-spacing:2px; text-transform:uppercase; margin-bottom:8px;">Estat actual</div>
|
||||||
|
<div style="color:#00C8DC; font-size:28px; font-weight:800;">${statusLabel.toUpperCase()}</div>
|
||||||
|
<div style="color:#8888AA; font-size:14px; margin-top:4px;">Comanda ${order.orderNumber}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p>Qualsevol dubte, contacta'ns i et responem de seguida.</p>
|
||||||
|
<p><strong style="color:#FFFFFF;">L'equip de Blood Bros Sport</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>`)
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Blood Bros Sport" <${process.env.SMTP_FROM ?? process.env.SMTP_USER}>`,
|
||||||
|
to: order.email,
|
||||||
|
subject: `Actualització comanda ${order.orderNumber} – ${statusLabel}`,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
export async function generateOrderNumber(): Promise<string> {
|
||||||
|
const count = await prisma.order.count()
|
||||||
|
const num = String(count + 1).padStart(3, '0')
|
||||||
|
return `CC2026-${num}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { google } from 'googleapis'
|
||||||
|
import type { Order } from '@prisma/client'
|
||||||
|
|
||||||
|
const SHEET_NAME = 'Comandes'
|
||||||
|
|
||||||
|
const HEADERS = [
|
||||||
|
'Núm. Comanda', 'Data', 'Estat', 'Producte',
|
||||||
|
'Talla Samarreta', 'Talla Mitjons',
|
||||||
|
'Nom', 'Cognoms', 'Email', 'Telèfon',
|
||||||
|
'Lliurament', 'Adreça', 'CP', 'Població', 'Província',
|
||||||
|
'Base (€)', 'Enviament (€)', 'Total (€)',
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRODUCT_NAMES: Record<string, string> = {
|
||||||
|
samarreta: 'Samarreta',
|
||||||
|
mitjons: 'Mitjons',
|
||||||
|
pack: 'Pack S+M',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Pendent',
|
||||||
|
PAID: 'Pagat',
|
||||||
|
PREPARING: 'Preparant',
|
||||||
|
SHIPPED: 'Enviat',
|
||||||
|
DELIVERED: 'Lliurat',
|
||||||
|
CANCELLED: 'Cancel·lat',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuth() {
|
||||||
|
const b64 = process.env.GOOGLE_CREDENTIALS_BASE64
|
||||||
|
if (!b64) throw new Error('GOOGLE_CREDENTIALS_BASE64 no configurada')
|
||||||
|
const credentials = JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'))
|
||||||
|
return new google.auth.GoogleAuth({
|
||||||
|
credentials,
|
||||||
|
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderToRow(order: Order): string[] {
|
||||||
|
return [
|
||||||
|
order.orderNumber,
|
||||||
|
new Date(order.createdAt).toLocaleDateString('ca-ES', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}),
|
||||||
|
STATUS_LABELS[order.status] ?? order.status,
|
||||||
|
PRODUCT_NAMES[order.product] ?? order.product,
|
||||||
|
order.sizeTshirt ?? '',
|
||||||
|
order.sizeSocks ?? '',
|
||||||
|
order.nom,
|
||||||
|
order.cognoms,
|
||||||
|
order.email,
|
||||||
|
order.telefon,
|
||||||
|
order.shipping === 'correos' ? 'Correos Express' : 'Recollida Corbins/Lleida',
|
||||||
|
order.adreca ?? '',
|
||||||
|
order.codiPostal ?? '',
|
||||||
|
order.poblacio ?? '',
|
||||||
|
order.provincia ?? '',
|
||||||
|
order.baseAmount.toFixed(2).replace('.', ','),
|
||||||
|
order.shippingAmount.toFixed(2).replace('.', ','),
|
||||||
|
order.totalAmount.toFixed(2).replace('.', ','),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureHeaders(sheets: ReturnType<typeof google.sheets>, sheetId: string) {
|
||||||
|
const res = await sheets.spreadsheets.values.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
range: `${SHEET_NAME}!A1:R1`,
|
||||||
|
})
|
||||||
|
const firstRow = res.data.values?.[0]
|
||||||
|
if (!firstRow || firstRow[0] !== HEADERS[0]) {
|
||||||
|
await sheets.spreadsheets.values.update({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
range: `${SHEET_NAME}!A1`,
|
||||||
|
valueInputOption: 'RAW',
|
||||||
|
requestBody: { values: [HEADERS] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendOrderToSheet(order: Order) {
|
||||||
|
const sheetId = process.env.GOOGLE_SHEET_ID
|
||||||
|
if (!sheetId || !process.env.GOOGLE_CREDENTIALS_BASE64) return
|
||||||
|
|
||||||
|
const auth = getAuth()
|
||||||
|
const sheets = google.sheets({ version: 'v4', auth })
|
||||||
|
|
||||||
|
await ensureHeaders(sheets, sheetId)
|
||||||
|
|
||||||
|
await sheets.spreadsheets.values.append({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
range: `${SHEET_NAME}!A:R`,
|
||||||
|
valueInputOption: 'RAW',
|
||||||
|
insertDataOption: 'INSERT_ROWS',
|
||||||
|
requestBody: { values: [orderToRow(order)] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAllOrdersToSheet(orders: Order[]) {
|
||||||
|
const sheetId = process.env.GOOGLE_SHEET_ID
|
||||||
|
if (!sheetId || !process.env.GOOGLE_CREDENTIALS_BASE64) return
|
||||||
|
|
||||||
|
const auth = getAuth()
|
||||||
|
const sheets = google.sheets({ version: 'v4', auth })
|
||||||
|
|
||||||
|
// Clear and rewrite all data
|
||||||
|
await sheets.spreadsheets.values.clear({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
range: `${SHEET_NAME}!A:R`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = [HEADERS, ...orders.map(orderToRow)]
|
||||||
|
|
||||||
|
await sheets.spreadsheets.values.update({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
range: `${SHEET_NAME}!A1`,
|
||||||
|
valueInputOption: 'RAW',
|
||||||
|
requestBody: { values: rows },
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.length - 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: '2024-06-20',
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
teal: {
|
||||||
|
DEFAULT: '#00C8DC',
|
||||||
|
50: '#E0FAFE',
|
||||||
|
100: '#B3F3FA',
|
||||||
|
200: '#66E8F6',
|
||||||
|
300: '#1ADCF1',
|
||||||
|
400: '#00C8DC',
|
||||||
|
500: '#009AAB',
|
||||||
|
600: '#007580',
|
||||||
|
700: '#005059',
|
||||||
|
800: '#002A2E',
|
||||||
|
900: '#000D0E',
|
||||||
|
},
|
||||||
|
cherry: {
|
||||||
|
DEFAULT: '#E84040',
|
||||||
|
50: '#FEE8E8',
|
||||||
|
100: '#FCC8C8',
|
||||||
|
200: '#F98888',
|
||||||
|
300: '#F05050',
|
||||||
|
400: '#E84040',
|
||||||
|
500: '#C82020',
|
||||||
|
600: '#A01818',
|
||||||
|
700: '#781010',
|
||||||
|
800: '#500808',
|
||||||
|
900: '#280404',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
DEFAULT: '#070714',
|
||||||
|
card: '#0E0E1F',
|
||||||
|
border: '#1C1C38',
|
||||||
|
muted: '#2A2A45',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
float: 'float 3s ease-in-out infinite',
|
||||||
|
'float-slow': 'float 5s ease-in-out infinite',
|
||||||
|
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
||||||
|
'countdown-flip': 'countdownFlip 0.3s ease-out',
|
||||||
|
'fade-up': 'fadeUp 0.6s ease-out',
|
||||||
|
'slide-in': 'slideIn 0.4s ease-out',
|
||||||
|
shimmer: 'shimmer 2s infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0px)' },
|
||||||
|
'50%': { transform: 'translateY(-12px)' },
|
||||||
|
},
|
||||||
|
countdownFlip: {
|
||||||
|
'0%': { transform: 'rotateX(90deg)', opacity: '0' },
|
||||||
|
'100%': { transform: 'rotateX(0deg)', opacity: '1' },
|
||||||
|
},
|
||||||
|
fadeUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
slideIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-20px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': { backgroundPosition: '-200% 0' },
|
||||||
|
'100%': { backgroundPosition: '200% 0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"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": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||