feat: Racing Cards landing page - Astro + Tailwind v4

- Mobile-first dark mode landing
- Lucide icons (zero emojis)
- Self-hostable (Dockerfile + nginx)
- 10 card images generated with Nano Banana 2
- Sections: Hero, How It Works, Card Gallery, Features, Early Access, Footer
- Simple HTML form (no Netlify dependency)
- Zero JS output, static HTML
This commit is contained in:
Mambo
2026-03-08 12:42:09 +01:00
commit 9646a9684e
30 changed files with 7052 additions and 0 deletions

View File

@@ -0,0 +1 @@
export default new Map();

View File

@@ -0,0 +1 @@
export default new Map();

199
.astro/content.d.ts vendored Normal file
View File

@@ -0,0 +1,199 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
}
declare module 'astro:content' {
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof AnyEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
export type ContentCollectionKey = keyof ContentEntryMap;
export type DataCollectionKey = keyof DataEntryMap;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
ContentEntryMap[C]
>['slug'];
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceContentEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}) = string,
> = {
collection: C;
slug: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
/** @deprecated Use `getEntry` instead. */
export function getEntryBySlug<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
// Note that this has to accept a regular string too, for SSR
entrySlug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
/** @deprecated Use `getEntry` instead. */
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
collection: C,
entryId: E,
): Promise<CollectionEntry<C>>;
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof AnyEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
entry: ReferenceContentEntry<C, E>,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
slug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof ContentEntryMap>(
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
): Promise<CollectionEntry<C>[]>;
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof AnyEntryMap>(
entry: AnyEntryMap[C][string],
): Promise<RenderResult>;
export function reference<C extends keyof AnyEntryMap>(
collection: C,
): import('astro/zod').ZodEffects<
import('astro/zod').ZodString,
C extends keyof ContentEntryMap
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
>;
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
export function reference<C extends string>(
collection: C,
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ContentEntryMap = {
};
type DataEntryMap = {
};
type AnyEntryMap = ContentEntryMap & DataEntryMap;
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = typeof import("../src/content.config.mjs");
export type LiveContentConfig = never;
}

2
.astro/types.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.DS_Store

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

9
astro.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
output: 'static',
vite: {
plugins: [tailwindcss()],
},
});

1
dist/_astro/index.BfoiI1Is.css vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/favicon.svg vendored Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0a0a12"/>
<text x="16" y="23" text-anchor="middle" font-size="20" font-family="sans-serif" font-weight="bold" fill="#e10600">R</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

BIN
dist/images/cards/hero-card.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
dist/images/cards/recta-aleron.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

47
dist/index.html vendored Normal file

File diff suppressed because one or more lines are too long

18
netlify.toml Normal file
View File

@@ -0,0 +1,18 @@
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "22"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/_astro/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

27
nginx.conf Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Gzip
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
}

6123
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "racing-cards-landing",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"astro": "^5.18.0",
"tailwindcss": "^4.2.1"
}
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0a0a12"/>
<text x="16" y="23" text-anchor="middle" font-size="20" font-family="sans-serif" font-weight="bold" fill="#e10600">R</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,71 @@
---
interface Card {
name: string;
description: string;
type: 'recta' | 'curva' | 'especial';
image?: string;
}
const cardTypes = {
recta: { label: 'Recta', borderClass: 'border-green-500/30', barClass: 'bg-green-500', textClass: 'text-green-400', bgClass: 'bg-green-500/10' },
curva: { label: 'Curva', borderClass: 'border-yellow-500/30', barClass: 'bg-yellow-500', textClass: 'text-yellow-400', bgClass: 'bg-yellow-500/10' },
especial: { label: 'Especial', borderClass: 'border-primary/30', barClass: 'bg-primary', textClass: 'text-primary', bgClass: 'bg-primary/10' },
};
// Lucide icon SVGs for card type icons
const typeIcons = {
recta: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
curva: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0"/><path d="M17 7m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0"/><path d="M7 15V9a5 5 0 0 1 10 0"/></svg>`,
especial: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
};
const cards: Card[] = [
{ name: 'Aleron Movil', description: 'Abre el aleron y vuela. Bonus de velocidad en zonas de recta.', type: 'recta', image: '/images/cards/recta-aleron.png' },
{ name: 'Potencia', description: 'Mas caballos, mas recta. Tu carta base para tramos rapidos.', type: 'recta' },
{ name: 'Rebufo', description: 'Pegate al de delante y aprovecha el aire.', type: 'recta' },
{ name: 'Acelerar', description: 'Salida de curva limpia, pisas a fondo.', type: 'recta' },
{ name: 'Curva Limpia', description: 'La linea perfecta. Ni un metro de mas.', type: 'curva' },
{ name: 'Frenada Tardia', description: 'Frena mas tarde que nadie. Alto riesgo, alta recompensa.', type: 'curva' },
{ name: 'Pit Stop', description: 'Neumaticos nuevos. Pierdes tiempo ahora, lo recuperas despues.', type: 'especial' },
{ name: 'Coche de Seguridad', description: 'Se compacta el peloton. Tu ventaja desaparece... o tu desventaja tambien.', type: 'especial' },
{ name: 'Bandera Roja', description: 'Carrera detenida. Caos para unos, oportunidad para otros.', type: 'especial' },
{ name: 'Lluvia', description: 'Cambia la pista entera. Intermedios o slicks?', type: 'especial' },
];
---
<section class="px-4 py-16 md:py-24 bg-surface/50 border-y border-white/5">
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-end mb-10">
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold tracking-tight">
Tu baraja,<br />tu estrategia
</h2>
</div>
<!-- Horizontal scroll on mobile -->
<div class="flex gap-4 overflow-x-auto pb-6 snap-x snap-mandatory hide-scrollbar -mx-4 px-4 md:grid md:grid-cols-3 lg:grid-cols-4 md:overflow-visible md:mx-0 md:px-0">
{cards.map((card) => {
const t = cardTypes[card.type];
return (
<div class={`min-w-[200px] md:min-w-0 snap-center shrink-0 md:shrink flex flex-col bg-surface rounded-xl border ${t.borderClass} overflow-hidden shadow-lg hover:-translate-y-1 hover:shadow-xl transition-all duration-200`}>
<div class={`h-1.5 ${t.barClass}`}></div>
{card.image ? (
<div class="h-28 overflow-hidden">
<img src={card.image} alt={card.name} class="w-full h-full object-cover" loading="lazy" />
</div>
) : null}
<div class={`px-3 py-2 ${t.bgClass} flex justify-between items-center`}>
<span class={`text-xs font-bold ${t.textClass} uppercase tracking-wider`}>{t.label}</span>
<span class={t.textClass}>
<Fragment set:html={typeIcons[card.type]} />
</span>
</div>
<div class="p-4 flex flex-col gap-2 flex-1">
<h4 class="text-text font-bold">{card.name}</h4>
<p class="text-xs text-text-muted leading-relaxed flex-1">{card.description}</p>
</div>
</div>
);
})}
</div>
</div>
</section>

View File

@@ -0,0 +1,57 @@
---
// Simple HTML form — no Netlify Forms, no honeypot, self-hostable
// Lucide: Flag (checkered flag equivalent)
---
<section id="early-access" class="px-4 py-16 md:py-24 relative overflow-hidden">
<!-- Decorative skewed bg -->
<div class="absolute inset-0 bg-primary/5 -skew-y-3 origin-top-left -z-10"></div>
<div class="bg-surface rounded-2xl p-8 md:p-12 relative border border-primary/20 shadow-[0_0_40px_rgba(225,6,0,0.15)] text-center max-w-lg mx-auto">
<!-- Lucide: Flag icon -->
<div class="flex justify-center mb-4 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg>
</div>
<h2 class="font-heading text-text text-3xl font-bold leading-tight mb-4 tracking-tight">
Apuntate a la parrilla de salida
</h2>
<p class="text-text-muted text-sm mb-6 max-w-sm mx-auto">
Los primeros en registrarse seran los primeros en jugar. Dejanos tu email.
</p>
<form
id="early-access-form"
method="POST"
action="/api/register"
class="flex flex-col gap-4"
>
<input
type="email"
name="email"
required
placeholder="tu@email.com"
autocomplete="email"
class="w-full bg-bg border-2 border-border rounded-full h-12 px-6 text-text placeholder:text-text-disabled
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
transition-colors duration-150"
/>
<button
type="submit"
class="w-full h-12 rounded-full bg-primary text-text text-base font-bold
shadow-[0_0_20px_rgba(225,6,0,0.3)]
hover:shadow-[0_0_30px_rgba(225,6,0,0.5)] hover:-translate-y-0.5
active:translate-y-0
transition-all duration-200 cursor-pointer"
>
Reservar mi plaza
</button>
</form>
<p class="text-xs text-text-disabled mt-4">
Solo email. Sin spam. Te escribimos cuando este listo.
</p>
</div>
</section>

View File

@@ -0,0 +1,57 @@
---
// Lucide icons inline SVG — no emojis
const features = [
{
// Lucide: Zap
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
title: 'Partidas de 15-20 minutos',
description: 'Una carrera completa en el tiempo de un cafe. Sin sesiones de 2 horas.',
iconBg: 'bg-primary/20',
iconColor: 'text-primary',
},
{
// Lucide: Car (using a racing-appropriate icon)
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`,
title: 'Mecanicas de automovilismo real',
description: 'Gestion de neumaticos, pit stops, coche de seguridad. Las mecanicas reales del motorsport son el motor del juego.',
iconBg: 'bg-accent/20',
iconColor: 'text-accent',
},
{
// Lucide: Users
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
title: 'Online con amigos',
description: 'Crea una sala, comparte el enlace, a correr. Multijugador desde cualquier dispositivo.',
iconBg: 'bg-blue-500/20',
iconColor: 'text-blue-400',
},
{
// Lucide: Ban
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
title: 'Sin pay-to-win',
description: 'Mismas cartas para todos. Aqui gana quien juega mejor. Punto.',
iconBg: 'bg-green-500/20',
iconColor: 'text-green-400',
},
];
---
<section class="px-4 py-16 md:py-24">
<div class="max-w-4xl mx-auto">
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold text-center mb-10 tracking-tight">
Por que Racing Cards
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6">
{features.map((feature) => (
<div class="bg-surface p-6 rounded-xl border border-white/5 flex flex-col gap-3 hover:border-accent/20 transition-colors duration-200">
<div class={`w-10 h-10 rounded-lg ${feature.iconBg} ${feature.iconColor} flex items-center justify-center`}>
<Fragment set:html={feature.icon} />
</div>
<h3 class="font-heading text-text font-bold text-lg">{feature.title}</h3>
<p class="text-text-muted text-sm leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>

View File

@@ -0,0 +1,26 @@
---
// Lucide: ExternalLink icon inline
---
<footer class="bg-surface border-t border-white/5 py-8 px-4 text-center">
<div class="flex flex-col gap-4 items-center max-w-4xl mx-auto">
<span class="font-heading font-bold text-xl text-text tracking-tight">RACING CARDS</span>
<p class="text-sm text-text-muted max-w-md">
Un juego de cartas de carreras hecho con estrategia (y sin microtransacciones).
</p>
<a
href="#"
class="text-sm text-accent hover:underline inline-flex items-center gap-1 transition-colors"
>
Probar version actual
<!-- Lucide: ChevronRight -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</a>
<p class="text-sm text-text-disabled mt-2">
&copy; 2026 Racing Cards. Hecho en Barcelona.
</p>
</div>
</footer>

View File

@@ -0,0 +1,13 @@
---
// Sticky header — mobile-first
---
<header class="sticky top-0 z-50 flex items-center justify-between px-4 py-3 bg-bg/90 backdrop-blur-md border-b border-border">
<span class="text-text text-xl font-bold font-heading tracking-tight">Racing Cards</span>
<a
href="#early-access"
class="bg-primary/10 text-primary px-4 py-1.5 rounded-full text-sm font-bold hover:bg-primary/20 transition-colors"
>
Acceso anticipado
</a>
</header>

79
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,79 @@
---
// Hero section — full viewport, centered, radial gradient bg
---
<section class="relative flex flex-col items-center justify-center text-center min-h-screen px-4 py-16 overflow-hidden">
<!-- Background gradient -->
<div class="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_center,_rgba(225,6,0,0.15)_0%,_transparent_60%)]"></div>
<!-- Speed lines decoration -->
<div class="speed-lines"></div>
<!-- Content -->
<div class="flex flex-col gap-4 max-w-md lg:max-w-lg relative z-10">
<!-- Badge -->
<div class="inline-flex items-center justify-center gap-2 bg-primary/20 text-accent rounded-full px-3 py-1 text-xs font-bold w-fit mx-auto border border-primary/30">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Acceso Anticipado Abierto
</div>
<!-- Heading -->
<h1 class="font-heading text-text text-4xl md:text-5xl lg:text-6xl font-bold leading-[1.1] tracking-tight">
Cartas, estrategia<br />y carreras. <span class="text-primary">Online.</span>
</h1>
<!-- Subtitle -->
<p class="text-text-muted text-lg leading-relaxed mt-2 lg:text-xl">
El card game de automovilismo por turnos. Trazados reales, partidas de 15 min y cero pay-to-win. Solo estrategia.
</p>
<!-- CTA -->
<a
href="#early-access"
class="inline-flex items-center justify-center w-full sm:w-auto min-w-[220px] h-14 px-8 mt-4
bg-primary text-text text-lg font-bold rounded-full
shadow-[0_0_20px_rgba(225,6,0,0.4)]
hover:shadow-[0_0_30px_rgba(225,6,0,0.6)] hover:-translate-y-0.5
transition-all duration-200 cursor-pointer"
>
Quiero acceso anticipado
</a>
<!-- Trust text -->
<p class="text-text-disabled text-sm mt-1">
Gratis. Sin tarjeta. Te avisamos cuando este listo.
</p>
</div>
<!-- Floating card visual -->
<div class="mt-12 relative w-56 h-72 mx-auto lg:mt-8" style="perspective: 1000px;">
<div class="absolute inset-0 bg-gradient-to-br from-accent/40 to-primary/40 rounded-xl blur-xl opacity-50 scale-95 translate-y-4"></div>
<div
class="w-full h-full bg-surface rounded-xl border border-white/10 overflow-hidden shadow-2xl relative flex flex-col
hover:rotate-0 transition-transform duration-500"
style="transform: rotateY(8deg) rotateZ(2deg);"
>
<!-- Card top half — use hero-card image if available -->
<div class="h-1/2 bg-gradient-to-br from-slate-800 to-slate-900 p-4 relative overflow-hidden">
<img
src="/images/cards/hero-card.png"
alt="Carta de Racing Cards - Trazada Perfecta"
class="absolute inset-0 w-full h-full object-cover"
loading="eager"
onerror="this.style.display='none'"
/>
<div class="absolute top-2 left-2 bg-bg/80 px-2 py-1 rounded text-xs font-bold text-accent z-10">CURVA</div>
</div>
<!-- Card bottom half -->
<div class="p-4 flex flex-col flex-1 bg-surface">
<h3 class="font-heading font-bold text-lg text-text mb-1">Trazada Perfecta</h3>
<p class="text-xs text-text-muted flex-1">
Ignora la penalizacion de desgaste en la proxima curva cerrada y manten la velocidad punta.
</p>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,64 @@
---
// Lucide icon SVGs inline
const steps = [
{
number: 1,
title: 'Elige tus cartas',
description: 'Cada turno juegas cartas de tu mano: rectas para ganar velocidad, curvas para no salirte, especiales para cambiar la carrera.',
borderColor: 'border-primary',
textColor: 'text-primary',
shadow: 'shadow-[0_0_15px_rgba(225,6,0,0.3)]',
// Lucide: Layers
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>`,
},
{
number: 2,
title: 'Compite en trazados reales',
description: 'Rectas, curvas, chicanes. El trazado dicta la estrategia. Aleron abierto en la recta o rebufo en el ultimo sector?',
borderColor: 'border-accent',
textColor: 'text-accent',
shadow: 'shadow-[0_0_15px_rgba(0,229,200,0.3)]',
// Lucide: Flag
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg>`,
},
{
number: 3,
title: 'Gana con la cabeza',
description: 'Turnos simultaneos, sin esperas. Gestiona neumaticos, calcula pit stops, lee a tu rival. 15-20 minutos y hay un ganador.',
borderColor: 'border-text',
textColor: 'text-text',
shadow: 'shadow-[0_0_15px_rgba(255,255,255,0.2)]',
// Lucide: Trophy
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>`,
},
];
---
<section class="px-4 py-16 md:py-24">
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold text-center mb-12 tracking-tight">
Asi se corre una carrera
</h2>
<div class="max-w-md mx-auto relative pl-4">
<!-- Timeline line -->
<div class="absolute left-[35px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-primary via-accent to-primary/20"></div>
{steps.map((step) => (
<div class="grid grid-cols-[60px_1fr] gap-x-4 mb-8 last:mb-0 relative">
<div class="flex flex-col items-center pt-1 z-10">
<div class={`w-12 h-12 rounded-full bg-surface border-2 ${step.borderColor} flex items-center justify-center ${step.shadow} ${step.textColor}`}>
<Fragment set:html={step.icon} />
</div>
</div>
<div class="flex flex-col pt-2 pb-4">
<h3 class="font-heading text-text text-xl font-bold mb-2">
{step.number}. {step.title}
</h3>
<p class="text-text-muted text-sm leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
</section>

70
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,70 @@
---
interface Props {
title?: string;
description?: string;
}
const {
title = 'Racing Cards — Cartas, estrategia y carreras. Online.',
description = 'El card game de automovilismo por turnos. Trazados reales, partidas de 15 min y cero pay-to-win. Solo estrategia.',
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site || 'https://racingcards.com');
const ogImage = new URL('/og-image.png', Astro.site || 'https://racingcards.com');
---
<!DOCTYPE html>
<html lang="es" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO -->
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:locale" content="es_ES" />
<meta property="og:site_name" content="Racing Cards" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<!-- Styles -->
<style>
@import '../styles/global.css';
</style>
</head>
<body class="min-h-screen overflow-x-hidden">
<!-- Skip navigation -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-text focus:rounded-md"
>
Saltar al contenido
</a>
<slot />
</body>
</html>

22
src/pages/index.astro Normal file
View File

@@ -0,0 +1,22 @@
---
import Layout from '../layouts/Layout.astro';
import Header from '../components/Header.astro';
import Hero from '../components/Hero.astro';
import HowItWorks from '../components/HowItWorks.astro';
import CardGallery from '../components/CardGallery.astro';
import Features from '../components/Features.astro';
import EarlyAccess from '../components/EarlyAccess.astro';
import Footer from '../components/Footer.astro';
---
<Layout>
<Header />
<main id="main-content">
<Hero />
<HowItWorks />
<CardGallery />
<Features />
<EarlyAccess />
</main>
<Footer />
</Layout>

123
src/styles/global.css Normal file
View File

@@ -0,0 +1,123 @@
@import "tailwindcss";
/* ============================================
THEME — Racing Cards Design System
============================================ */
@theme {
/* Colors — Base */
--color-bg: #0a0a12;
--color-surface: #161621;
--color-surface-alt: #1e1e3a;
--color-border: #2a2a4a;
/* Colors — Primary (Red) */
--color-primary: #e10600;
--color-primary-hover: #c00500;
--color-primary-active: #a00400;
--color-primary-subtle: #2a0a0a;
/* Colors — Accent (Cyan) */
--color-accent: #00e5c8;
--color-accent-hover: #00c4ab;
--color-accent-subtle: #0a1e1a;
/* Colors — Text */
--color-text: #f0f0f5;
--color-text-muted: #8888a0;
--color-text-disabled: #505068;
/* Colors — States */
--color-success: #22c55e;
--color-error: #ef4444;
/* Typography */
--font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
/* Border radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-card: 14px;
/* Shadows */
--shadow-card: 0 4px 24px oklch(0% 0 0 / 0.4);
--shadow-glow-accent: 0 0 20px oklch(75% 0.18 180 / 0.3), 0 0 60px oklch(75% 0.18 180 / 0.1);
--shadow-glow-primary: 0 0 20px oklch(55% 0.28 29 / 0.3), 0 0 60px oklch(55% 0.28 29 / 0.1);
}
/* ============================================
BASE STYLES
============================================ */
@layer base {
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
background-color: var(--color-bg);
color: var(--color-text-muted);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4 {
font-family: var(--font-heading);
color: var(--color-text);
text-wrap: balance;
}
p, li {
text-wrap: pretty;
}
}
/* ============================================
ANIMATIONS
============================================ */
@layer components {
/* Speed lines — Hero decoration */
.speed-lines {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.speed-lines::before,
.speed-lines::after {
content: '';
position: absolute;
height: 1px;
width: 200px;
background: linear-gradient(90deg, transparent, oklch(75% 0.18 180 / 0.4), transparent);
animation: speed-line 3s linear infinite;
}
.speed-lines::before { top: 30%; }
.speed-lines::after { top: 70%; width: 150px; animation-delay: 1.5s; }
@keyframes speed-line {
from { translate: -200px 0; }
to { translate: calc(100vw + 200px) 0; }
}
/* Hide scrollbar utility */
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}