Initial commit – Cursa de la Cirera 2026 e-commerce

Next.js 14 + Prisma + Stripe + Google Sheets + Nodemailer
This commit is contained in:
2026-06-17 12:02:28 +02:00
commit 60f6e9d2e1
45 changed files with 9974 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.next
node_modules
*.md
.env
.env.local
.env.local.example
*.log
data/
dev.db
+46
View File
@@ -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=
+45
View File
@@ -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/
+57
View File
@@ -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"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+25
View File
@@ -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:
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
unoptimized: true,
},
}
export default nextConfig
+6707
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
+49
View File
@@ -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
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+472
View File
@@ -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>
)
}
+127
View File
@@ -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 }
)
}
}
+27
View File
@@ -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 })
}
+81
View File
@@ -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 })
}
+21
View File
@@ -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 })
}
+69
View File
@@ -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 })
}
+314
View File
@@ -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;
}
}
+23
View File
@@ -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>
)
}
+785
View File
@@ -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 XXSXL'],
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)} />
)}
</>
)
}
+220
View File
@@ -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>
)
}
+94
View File
@@ -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>
)
}
+108
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+224
View File
@@ -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,
})
}
+7
View File
@@ -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}`
}
+11
View File
@@ -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
+122
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
})
+84
View File
@@ -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
+20
View File
@@ -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"]
}