diff --git a/.gitignore b/.gitignore index 11604ee..0a9f2ed 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ pnpm-debug.log* .wrangler/ .dev.vars worker-configuration.d.ts + +# caché de fuentes de scripts/gen-og.mjs: los TTF se bajan al generar las +# tarjetas OG y no se commitea ninguna fuente (tendría que ir con su OFL) +scripts/.fontcache/ diff --git a/README.md b/README.md index ac71d87..f08378e 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,49 @@ The site is deployed to Cloudflare Workers under the name `nan-website` (see `wr All runtime secrets must be set in the Cloudflare dashboard (or via `wrangler secret put`), not in the repository. +### Rollback + +Because every `wrangler deploy` publishes a numbered Version, Cloudflare keeps +the history and a rollback needs no rebuild. It is a code-and-assets rollback +only: it does **not** revert secrets, `vars`, or anything living in cloud-api. + +**Before merging anything large, write down the version that is live:** + +```bash +npx wrangler deployments status # shows the Version ID currently serving +npx wrangler versions list # the 10 most recent Versions +``` + +**To roll back:** + +```bash +npx wrangler rollback # omit the id to pick the previous one +npx wrangler deployments status # confirm what is serving now +``` + +Give it a minute and re-check the site: the Worker flips immediately, but a +CDN-cached asset can outlive the rollback for a short while. + +### Previewing a release before it serves traffic + +`wrangler deploy` sends 100% of traffic to the new Version straight away. To see +a build on real Cloudflare infrastructure first, upload a Version **without** +deploying it. This is worth doing for releases that replace many pages at once: + +```bash +npm run build +npx wrangler versions upload # prints a preview URL, serves no traffic +``` + +Once it looks right, either merge as usual or promote that exact Version: + +```bash +npx wrangler versions deploy @100 +``` + +`wrangler versions deploy` also splits traffic (for example `@10` for 10%), +which is the safer path for a change that touches routing or URLs. + ## CI/CD Two GitHub Actions workflows live in `.github/workflows/`: diff --git a/astro.config.mjs b/astro.config.mjs index 415907f..fbe990a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -9,10 +9,20 @@ import rehypePrettyCode from 'rehype-pretty-code'; // https://astro.build/config export default defineConfig({ + site: 'https://nan.builders', output: 'server', adapter: cloudflare(), integrations: [preact(), mdx()], + // El idioma va en la RUTA, no en un query param: EN en `/`, ES en `/es/`. + // Un `?lang=` no es una señal de idioma para los buscadores y no se puede + // acompañar de hreflang; con rutas sí, y además se pueden prerenderizar. + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + routing: { prefixDefaultLocale: false }, + }, + // We do not use Astro sessions in v1 (no auth). The Cloudflare adapter // otherwise auto-enables a KV-backed session driver and tries to inject a // "SESSION" KV binding into the generated wrangler config. Pointing the diff --git a/i18n/en.json b/i18n/en.json index 7338550..cd1f445 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,233 +1,4 @@ { - "hero": { - "subheadline": "Shared GPUs. Open-source models. Closed community.", - "description": "Builders sharing GPUs to run the latest open AI models without token limits.", - "badge": "Limited spots", - "cta": "Join", - "docs": "Docs", - "scroll": "scroll", - "aria": { - "join": "Join the waitlist", - "docs": "Read the documentation", - "scroll": "Go to next section" - } - }, - "about": { - "label": "// what is nan", - "heading": "Builders sharing dedicated GPUs to run open models and burn tokens without limits.", - "p1": "Closed-model APIs are convenient but expensive and limited. Open models are free but running them seriously requires hardware almost no one has at home.", - "p2": "NaN solves this by sharing powerful GPUs among builders who actually use them, splitting the real cost of infrastructure.", - "p3": "A closed community by design, limited to the real capacity of shared GPUs. When it fills up, we open a waitlist.", - "p4": "The community lives on Discord. The website only manages access. Every member pays to be here. Only those who truly want to build make it in." - }, - "server": { - "label": "// inference cluster", - "heading": "Inference cluster", - "description": "Shared inference cluster. Models chosen by the community every quarter.", - "models": "// current models", - "comingSoon": "coming soon", - "guarantees": { - "0": "Your code doesn't train any models", - "1": "Data in EU, no prompt logs", - "2": "Unlimited tokens on cluster models", - "3": "Personal API key for each member" - } - }, - "privacy": { - "label": "// privacy", - "heading": "Pure inference. Zero logs.", - "p1": "NaN is inference infrastructure. There is no logging of prompts, model responses, nothing. Everything lives in your local session, on whatever client, agent, or custom app you use.", - "p2": "The API is pure inference. Nothing you do with it is logged except server-side metrics (tokens/s, RPM, etc.) for cluster maintenance and monitoring only.", - "p3": "Your code doesn't train any models. Your prompts aren't stored. Your data doesn't leave your session.", - "points": { - "noLogs": "Zero logs: no prompt or response logging", - "localData": "Everything lives in your local client or agent session", - "euProcessing": "Processing in the European Union", - "noTraining": "Your code doesn't train any models", - "metrics": "Server metrics only for cluster maintenance (tokens/s, RPM)" - } - }, - "modelVoting": { - "label": "// how models are chosen", - "heading": "Models chosen by the community. Refreshed every quarter.", - "rules": { - "0": "Every 3 months, members vote on the new models that will run on the cluster.", - "1": "Candidates are pre-selected technically: they must fit in the cluster capacity.", - "2": "Voting happens on Discord, open to all members.", - "3": "The open models ecosystem moves fast. So does NaN." - }, - "models": "// current models" - }, - "founder": { - "label": "// who's behind", - "heading": "The NaN community is led by Cristian Córdova and Borja Perez.", - "bio": { - "0": "Founder of Helmcode, working with startups on cloud infrastructure, AI tooling and open source.", - "1": "Thanks to Helmcode we offer open model inference with unlimited token plans for enterprises, I share my experiments on social media and AI research." - }, - "quote": "I started NaN because I wanted to run serious open models without usage limits, and share and learn with people in the same boat: builders who actually burn tokens.", - "links": { - "twitter": "Twitter", - "linkedin": "LinkedIn", - "github": "GitHub" - }, - "companyIntro": "The NaN community exists thanks to Helmcode, the company behind all the inference infrastructure that powers the community." - }, - "pricing": { - "label": "// pricing", - "subheading": "One tier per region. No upsells. No trial.", - "plan1": { - "name": "NaN Member EU", - "flagLabel": "Europe", - "price": "70€", - "period": "/mo", - "taxNote": "VAT included" - }, - "plan2": { - "name": "NaN Member USA/Latam", - "flagLabel": "USA / Latam", - "price": "$75", - "period": "/mo", - "taxNote": "Taxes included" - }, - "plan3": { - "name": "NaN Community", - "flagLabel": "Community", - "price": "$14.99", - "period": "/mo", - "taxNote": "Taxes included", - "badge": "// community", - "noGpuTag": "No GPU access · Discord only" - }, - "limited": "Limited spots", - "features": { - "0": "Access to shared cluster — open models with no token caps", - "1": "DeepSeek V4-Flash (frontier 284B, 1M context, advanced reasoning) — 500M tokens/month", - "2": "Open models chosen by the community", - "3": "Personal API key compatible with OpenAI", - "4": "Private Discord channels for members only", - "5": "Voice in quarterly model voting", - "6": "Month to month, no commitment" - }, - "communityFeatures": { - "0": "Discord access", - "1": "Member-only channels", - "2": "Live discussions, builds, AMAs", - "3": "Reserved spot in line for inference" - }, - "cta": "Join", - "communityCta": "Join the community", - "footerNote": "Limited spots. When it fills up, waitlist.", - "communityFooterNote": "No commitment. Cancel anytime." - }, - "cta": { - "label": "// join", - "heading": "A community of builders. Limited spots. Shared GPUs with unlimited usage.", - "description": "We share dedicated GPUs to run open models and burn tokens without limits. Leave your email and we'll reach out when we open payments." - }, - "faq": { - "label": "// faq", - "heading": "What you'll probably ask.", - "items": { - "0": { - "id": "what-is-nan", - "question": "What is NaN?", - "answer": "NaN is a closed community of builders who share access to dedicated GPUs to run open-source AI models. Every member pays to be here, and there's only room for those who will actually build." - }, - "1": { - "id": "how-it-works", - "question": "How does the cluster work?", - "answer": "Each community member shares a dedicated inference cluster. Cluster models run 24/7 and each member gets a personal OpenAI-compatible API key to use the models as they want, with no token caps." - }, - "2": { - "id": "token-limits", - "question": "Are there really no token limits?", - "answer": "You can burn tokens without limits on the smaller cluster models. Frontier models (like DeepSeek V4-Flash) come with a generous monthly token allowance per member to keep the plan sustainable. No surprises on the bill: the flat subscription price is all you pay." - }, - "3": { - "id": "model-voting", - "question": "How are models chosen?", - "answer": "Every quarter, community members vote on which new models will run on the cluster. Candidates are pre-selected technically to ensure they fit within the cluster capacity. Voting happens on Discord, open to all members." - }, - "4": { - "id": "where-does-data-go", - "question": "Where does my data go?", - "answer": "Data is processed in the European Union, with no prompt logging. Your API key is personal and non-transferable. We don't share data between members or sell information to third parties. Your code doesn't train any models." - }, - "5": { - "id": "who-is-this-for", - "question": "Who is NaN for?", - "answer": "For builders who want to run serious AI models without the overhead of managing infrastructure. If you're a startup, researcher, or maker who needs inference at scale without token limits, NaN is for you. It's not for casual experimentation." - }, - "6": { - "id": "why-open-models", - "question": "Why only open models?", - "answer": "Because we believe real innovation comes from tools you can audit, modify, and use without restrictions. Closed models are convenient but create dependency. With open models you have full control over your stack." - }, - "7": { - "id": "can-i-train", - "question": "Can I train models?", - "answer": "No. NaN is for inference, not training. The GPUs are optimized to run existing models at high speed, not for fine-tuning. If you need training, there are other platforms for that." - }, - "8": { - "id": "payment-methods", - "question": "What payment methods do you accept?", - "answer": "We currently accept credit/debit cards. Prices include taxes (VAT for EU, taxes for USA/Latam). You can cancel anytime, no commitment." - }, - "9": { - "id": "join-community", - "question": "How do I join the community?", - "answer": "Leave your email on the waitlist and we'll reach out when we open payments. The community lives on Discord — once you join, you get access to all private channels, quarterly votes, and direct support from the team." - } - } - }, - "footer": { - "brand": "nan.builders" - }, - "waitlist": { - "registered": "// registered", - "interest": "// interest registered", - "followUp": "We don't send newsletters. We'll only write when we open payments.", - "confirmation": { - "label": "// confirmation", - "heading": "Are you sure you want to join?", - "p1": "You're joining the waitlist for a paid private community. When we open payments, a monthly subscription will be required to access shared GPUs and the models chosen by the community.", - "p2": "We only want people with real intention to pay to join. If you're just curious, better not join.", - "cancel": "Cancel", - "confirm": "Yes, sign me up", - "submitting": "Sending…" - }, - "honeypot": "Do not fill this field", - "email": { - "label": "email", - "placeholder": "you@email.com" - }, - "region": { - "label": "region", - "default": "Select your region" - }, - "submit": { - "idle": "Join the waitlist", - "submitting": "Sending…" - }, - "footer": "No spam. No newsletters. We only write when we open payments." - }, - "waitlistHelpers": { - "regions": { - "europa": "Europe", - "latam": "LATAM", - "usa": "USA" - }, - "successInterest": "Noted. We're available for ${regionLabel}. We'll let you know as soon as we open payments.", - "successRegistered": "You're on the list. We'll accept members in order of arrival when we open payments.", - "errors": { - "invalidEmail": "Invalid email. Check the format.", - "invalidRegion": "Select a region.", - "rateLimited": "Too many attempts. Wait a minute.", - "networkError": "No connection. Try again.", - "serverError": "Something went wrong on the server. Try again in a moment." - } - }, "community": { "label": "// community", "pageTitle": "NaN Community", @@ -427,5 +198,428 @@ "total": "Total", "winner": "🏆 Winner" } + }, + "nan": { + "meta": { + "title": "NaN — Build without limits", + "description": "A community of builders sharing GPUs to run open models at a flat rate. OpenAI-compatible API, zero logs, spots limited by compute.", + "siteName": "NaN", + "ogLocale": "en_US" + }, + "a11y": { + "skip": "Skip to content", + "brandHome": "NaN, home", + "menuOpen": "Open menu", + "menuClose": "Close menu", + "navPrimary": "Primary", + "navLegal": "Legal", + "navDocs": "Docs sections", + "language": "Language", + "email": "Email", + "region": "Region", + "close": "Close", + "cookies": "Cookie notice", + "projectDetail": "Project detail", + "openProject": "Open {name}", + "projectsRail": "Community projects, horizontal carousel", + "membersOnly": "{title}, members only", + "gpuDither": "NVIDIA RTX 5090, dithered illustration", + "crt404": "A retro CRT computer showing a blue screen with a sad face and the message NO TOKENS" + }, + "nav": { + "models": "Models", + "community": "Community", + "events": "Events", + "projects": "Projects", + "docs": "Docs", + "login": "Login" + }, + "hero": { + "line1": "Build without", + "line2": "limits.", + "sub": "Don't run out of tokens. Ever.", + "body": "NaN is the community of builders sharing GPUs to run AI open models. Build whatever you need, with no token cap. You can just do things.", + "spots": "Limited spots", + "waitline": "Join the waitlist to unlock your subscription. Just leave your email.", + "emailPlaceholder": "you@email.dev", + "regionLabel": "Select your Region", + "submit": "Join waitlist", + "sending": "Sending…", + "okRegistered": "You're on the waitlist.", + "okPosition": "position", + "okText": "We'll approve your spot in the next few days. Keep an eye on your inbox ^__^", + "errEmail": "That email does not look valid.", + "errRegion": "Pick a region.", + "errNetwork": "The network failed, not you. Try again.", + "errGeneric": "Something broke on our side. Try again in a bit.", + "okInterest": "Noted. We are not open in your region yet, but we will write as soon as we are.", + "errRateLimited": "Too many attempts. Wait a minute." + }, + "why": { + "label": "what is nan", + "title1": "What is NaN.", + "title2": "The Builders Community", + "lead": "Builders sharing dedicated GPUs to run open models and burn tokens without limits.", + "p": [ + "Closed-model APIs are convenient but expensive and limited. Open models are free but running them seriously requires hardware almost no one has at home.", + "NaN solves this by sharing powerful GPUs among builders who actually use them, splitting the real cost of infrastructure.", + "A closed community by design, limited to the real capacity of shared GPUs. When it fills up, we open a waitlist.", + "The community lives on Discord. The website only manages access. Every member pays to be here. Only those who truly want to build make it in." + ] + }, + "built": { + "label": "built with nan", + "title1": "We don't burn tokens.", + "title2": "We build.", + "sub": "Real projects built by NaN members using the shared inference cluster. This is what happens when builders get serious hardware.", + "railLabel": "community projects", + "seeAll": "See all projects", + "addYours": "Add yours" + }, + "models": { + "label": "the cluster", + "titleSuffix": "models.", + "titleLine2": "One API.", + "sub": "The full open-model inference stack, voted by the community every quarter. Data stays in the EU, with nothing logged.", + "unmetered": "unmetered", + "cta": "Join the waitlist" + }, + "privacy": { + "label": "privacy", + "title1": "Pure inference.", + "title2": "Zero logs.", + "intro": "NaN is inference infrastructure. There's no logging of prompts, model responses, nothing. Everything lives in your local session, on whatever client, agent or custom app you use.", + "guarantees": [ + "Zero logs. No prompt or response logging.", + "Everything lives in your local client or agent session.", + "Processing in the European Union.", + "Your code doesn't train any models.", + "Server metrics only, for cluster maintenance (tokens/s, RPM)." + ], + "termNote": "# 0 bytes. 0 rows. nothing stored, by design.", + "termFoot": "leaving the server: tokens/s · RPM · cluster metrics" + }, + "pricing": { + "label": "membership", + "title1": "One community.", + "title2": "Two tiers.", + "sub": "The community is the product. Inference is the superpower you add. No upsells, no trial.", + "perVat": "/mo · VAT included", + "perTaxes": "/mo · taxes included", + "inferenceIncluded": "inference included", + "memberIncludes": [ + "Access to the shared cluster. Open models, no token caps.", + "DeepSeek V4-Flash (frontier 284B, 1M context, reasoning). 500M tokens/month.", + "Open models chosen by the community", + "Personal API key compatible with OpenAI", + "Private Discord channels for members only", + "Events, workshops and hackathons", + "Voice in the quarterly model vote" + ], + "memberCond": "Month to month, no commitment. Limited spots: when it fills up, waitlist.", + "memberCta": "Join the waitlist", + "communityIncludes": [ + "Discord access", + "Member-only channels", + "Live discussions, builds, AMAs", + "Events, workshops and hackathons", + "A reserved spot in line for inference" + ], + "communityCond": "No commitment. Cancel anytime.", + "communityCta": "Join the community" + }, + "who": { + "label": "who's behind", + "title": "The NaN community is led by Cristian Córdova and Borja Perez.", + "quote": "I started NaN because I wanted to run serious open models without usage limits, and share and learn with people in the same boat: builders who actually burn tokens.", + "quoteBy": "Cristian Córdova", + "cristianRole": "Founder of Helmcode", + "cristianLoc": "Tenerife, Islas Canarias", + "cristianBio": [ + "Founder of Helmcode, working with startups on cloud infrastructure, AI tooling and open source.", + "Thanks to Helmcode we offer open model inference with unlimited token plans for enterprises. I share my experiments on social media and AI research." + ], + "borjaRole": "Marketing Lead", + "borjaLoc": "Valencia, Spain", + "borjaBio": [ + "Builder with marketing skills. Helping Cristian with comms and community.", + "Our goal is to create the best open-models community in the world, helping people create and ship products with open AI." + ] + }, + "faq": { + "label": "faq", + "title": "What you'll probably ask.", + "items": [ + { + "q": "What is NaN?", + "a": "A closed community of builders sharing dedicated GPUs to run open models. Every member pays to be here, and there's only room for people who actually build." + }, + { + "q": "How does the cluster work?", + "a": "The models run 24/7 on a shared inference cluster. Each member gets a personal API key compatible with OpenAI: change the base_url and you're in." + }, + { + "q": "Is it really unlimited tokens?", + "a": "On the cluster models, no counter. The frontier ones (like DeepSeek V4-Flash) come with 500M tokens/month per member so the plan holds up. That limit is published here, not in fine print. The bill is the fixed subscription price and it doesn’t move." + }, + { + "q": "How are the models chosen?", + "a": "Every quarter members vote on Discord which new models join the cluster. Candidates are pre-selected technically: they have to fit the real capacity. The open ecosystem moves fast, and so does the cluster." + }, + { + "q": "Where does my data go?", + "a": "Processed in the European Union, with no logging of prompts or responses. Your API key is personal and non-transferable. Your code trains no model and your data never leaves your session." + }, + { + "q": "Who is NaN for?", + "a": "Builders who want to run open models seriously without building the infrastructure: indie hackers, makers, people with agents and side projects in production. It's not for someone who just wants to try a couple of prompts." + }, + { + "q": "Why only open models?", + "a": "Because they can be audited, modified and used without asking permission. Closed ones are convenient but create dependency. With open models, the stack is yours." + }, + { + "q": "Can I train models?", + "a": "No. NaN is inference, not training. The GPUs are optimized to run existing models at high speed, not for fine-tuning." + }, + { + "q": "What payment methods do you accept?", + "a": "Credit or debit card. Prices include taxes (VAT in the EU, taxes in USA/Latam). No commitment: cancel anytime." + }, + { + "q": "How do I get in?", + "a": "Leave your email on the waitlist and we'll write when a spot opens. There's a waitlist because there are no more GPUs: when compute comes in, people come in." + } + ] + }, + "footer": { + "cta1": "Start building.", + "cta2": "Start burning tokens.", + "go": "Join the waitlist", + "privacy": "Privacy", + "terms": "Terms", + "cookies": "Cookies", + "community": "Community", + "notice": "We use strictly necessary cookies to run the site. No analytics, no advertising, no cross-site tracking, ever.", + "copyPre": "NaN is the inference community of", + "copyRights": "All rights reserved.", + "copyFrom": "From Canary Islands to 🌎", + "colExplore": "explore", + "colLegal": "legal", + "colConnect": "connect" + }, + "cookie": { + "title": "Purrr", + "line1": "We don't track cookies, but...", + "q": "Do you love cats?", + "yes": "Yes", + "yesIDo": "Yes, I do" + }, + "notFound": { + "metaTitle": "404 — NaN", + "eyebrow": "error 404 // no tokens", + "title1": "This page ran", + "title2": "out of tokens.", + "body": "The address you typed doesn't exist, or it never did. Either way, there's nothing to render here. You, though, still fit here.", + "back": "Back to home", + "join": "Join the waitlist" + }, + "projects": { + "metaTitle": "Projects — NaN", + "metaDesc": "What the NaN community is building.", + "eyebrow": "// projects", + "title": "Community projects.", + "subPre": "What the NaN community is building. Star your favourites on", + "filterAll": "all", + "groupTop": "top projects", + "groupAll": "// all projects", + "openApp": "Open app ↗", + "viewRepo": "View repo ↗" + }, + "docs": { + "metaTitle": "Docs — NaN", + "metaDesc": "Connect your tools to the NaN cluster. OpenAI-compatible API.", + "eyebrow": "// docs", + "navIntro": "Introduction", + "navGetting": "Getting Started", + "navModels": "Models", + "navExamples": "Examples", + "navSupport": "Support", + "title": "Welcome to NaN.", + "leadA": "This doc explains how to connect your tools to our GPUs. The cluster runs open models with an OpenAI-compatible API. If something accepts a ", + "leadB": ", it works with NaN.", + "apiKeyH": "Your API key", + "apiKeyP1": "To get your API key you must be a NaN community member. Generate it from your user settings under ", + "apiKeyStrong": "API Keys", + "apiKeyP2": " on ", + "apiKeyP3": ". It is personal and non-transferable.", + "rateLimitsH": "Rate limits", + "rpmK": "requests per minute", + "parK": "max parallel", + "nextH": "What to do next", + "next": [ + { + "t": "Getting Started", + "d": "Endpoint, auth and setup. Point any OpenAI-compatible client at the cluster." + }, + { + "t": "Models", + "d": "The full catalogue, capabilities and limits." + }, + { + "t": "Examples", + "d": "Snippets in Python, Node.js and curl." + }, + { + "t": "Support", + "d": "Ask in the #support channel on Discord." + } + ] + }, + "events": { + "metaTitle": "Events — NaN", + "metaDesc": "Workshops, events and hackathons inside the NaN community.", + "heroEyebrow": "// events", + "heroTitle1": "Live inside", + "heroTitle2": "the community.", + "heroSub": "Workshops, events and hackathons happen inside NaN. The recordings live in the community. You log in to watch them.", + "membersOnly": "members only", + "loginToSee": "Log in to see it", + "monthLabel": "this month in nan", + "monthTitle": "This month in NaN.", + "monthSub": "The next 30 days. The community proposes and votes the agenda on Discord.", + "monthLockTitle": "members only", + "monthLockBody": "The full agenda lives inside the community.", + "weekdays": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "monthEmpty": "Nothing scheduled in the next 30 days yet. The community proposes the agenda on Discord.", + "types": { + "workshop": "workshop", + "event": "event", + "hackathon": "hackathon" + }, + "workshopsLabel": "workshops", + "workshopsTitle": "Workshops.", + "workshopsSub": "Hands-on sessions on agents, tooling and shipping with open models. Recorded for members.", + "eventsLabel": "events", + "eventsTitle": "Events.", + "eventsSub": "Community moments: milestones, AMAs and get-togethers.", + "hackathonsLabel": "hackathons", + "hackathonsTitle": "Hackathons.", + "hackathonsSub": "Build weeks on the cluster. Teams, a theme and a demo day.", + "watchNote": "log in to watch", + "hackathon": { + "blurb": "7 days to build with the cluster's open source models. Teams of 4, balanced. Prize: 1 month of NaN free.", + "when": "June 7-13 · build week · Sunday 14th: Demo Day", + "allowed": "Allowed models: Qwen 3.6 or Gemma 4. The project must be open source. Free theme.", + "statusLabel": "registration closed", + "statusBody": "The registration window has closed. We hope to see you at the next NaN hackathon!" + } + }, + "community": { + "metaTitle": "Community — NaN", + "metaDesc": "NaN is the springboard for builders shipping with open AI. Build in public, together.", + "heroEyebrow": "// the NaN community", + "heroTitle": "Create. Share. Help.", + "heroSub": "NaN is the springboard for builders shipping with open AI. Here we Build in Public: you show what you make, you learn from the rest and you ship with company.", + "ctaPrimary": "Join the community", + "ctaSecondary": "See the roadmap", + "whatLabel": "what happens here", + "whatTitle": "A community to build, not just to burn tokens", + "whatIntro": "Everything we run in the NaN community has one goal: that you leave with something learned, something built, or someone new to build with.", + "pillars": [ + { + "name": "Discord", + "body": "Where the community lives. This is where the events, the announcements and the day-to-day happen: model discussions, papers worth reading, deployment questions and whatever you are building.", + "freq": "always open · the meeting point" + }, + { + "name": "Events, workshops and hackathons", + "body": "Hands-on learning every month. We bring in people who know their stuff to run sessions on AI, open source and everything around building with open models. And we run hackathons to challenge ourselves to build products.", + "freq": "1x/month sessions · 1x/quarter hackathon" + }, + { + "name": "Built with NaN", + "body": "The community projects section. We help builders get visibility for what they build: we amplify the best projects on social with the NaN and Helmcode megaphone.", + "freq": "your project, in front of more people" + }, + { + "name": "Café Helmcode", + "body": "The in-person event. A coffee across different cities to put faces to names, talk without rushing and build as a community. Because some conversations only happen away from a screen.", + "freq": "city by city · see you soon" + } + ], + "progressLabel": "in progress", + "progressTitle": "What we are cooking", + "progressIntro": "Three initiatives already under construction.", + "inProgressTag": "in progress", + "initiatives": [ + { + "name": "NaN Combinator", + "body": "Our incubator for builders. We want product companies to be born out of NaN, and we will help however we can: training in sales, product, marketing and scaling. Every 6 months we will run a small cohort to help build and scale their ideas, with a Demo Day at the end. The goal: that soon you can say that cool product you use was built in the community." + }, + { + "name": "Ambassador Plan", + "body": "NaN's ambassador program. Our way of rewarding the people who spread the word: we help you set up and host events on behalf of NaN. Ambassadors are picked from the community, on merit. If you are already moving NaN on your own, this is about giving you fuel." + }, + { + "name": "Open source collaborative project", + "body": "A NaN repo maintained by everyone. It could be a useful library, a Spanish benchmark of open models or a dataset. What matters: that it belongs to the community, serves the ecosystem and has your name in the commits." + } + ], + "roadmapLabel": "roadmap", + "roadmapTitle": "What is coming", + "roadmapIntro": "We have a lot of plans, but we cannot tackle everything at once. So we go step by step, and in the open.", + "membersLabel": "nan_members", + "roadmapColInitiative": "initiative", + "roadmapColStatus": "status", + "roadmap": [ + { + "initiative": "1,000 members", + "detail": "this year's goal", + "status": "in_progress" + }, + { + "initiative": "Ambassador program", + "detail": "pilot with someone from the community", + "status": "next" + }, + { + "initiative": "Recurring online events", + "detail": "workshops, webinars, talks; ambassadors tearing apart the repo of the moment, or AMAs with outside guests (people from Hugging Face, for example)", + "status": "in_progress" + }, + { + "initiative": "In-person event", + "detail": "if we gather enough people", + "status": "soon" + }, + { + "initiative": "Hackathons", + "detail": "1 per quarter; some with a partner and cool prizes", + "status": "planned" + }, + { + "initiative": "Community projects", + "detail": "spotlight and launch projects through Helmcode's channels; maybe even a quarterly Demo Day", + "status": "planned" + }, + { + "initiative": "Mini-challenges", + "detail": "weekend challenges, model battles, 'kill the wrapper' rounds cloning products", + "status": "planned" + } + ], + "closeTitle": "Build together", + "closeText": "If you are building with open AI, or you want to start, this is your place. Come in, introduce yourself and show us what you are working on.", + "closeCta": "Join the community" + } } } diff --git a/i18n/es.json b/i18n/es.json index bdfc3c2..99b1905 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -1,233 +1,4 @@ { - "hero": { - "subheadline": "GPUs compartidas. Modelos abiertos. Comunidad cerrada.", - "description": "Builders compartiendo GPUs para correr los últimos modelos de IA abiertos sin límites de tokens.", - "badge": "Plazas limitadas", - "cta": "Únete", - "docs": "Docs", - "scroll": "scroll", - "aria": { - "join": "Unirse a la lista de espera", - "docs": "Leer la documentación", - "scroll": "Ir a la siguiente sección" - } - }, - "about": { - "label": "// what is nan", - "heading": "Builders compartiendo GPUs dedicadas para correr modelos abiertos para quemar tokens sin límites.", - "p1": "Las APIs de modelos cerrados son cómodas pero caras y limitadas. Los modelos abiertos son gratis pero correrlos en serio requiere hardware que casi nadie tiene en casa.", - "p2": "NaN resuelve esto compartiendo GPUs potentes entre builders que las usan de verdad, repartiendo el coste real de la infraestructura.", - "p3": "Una comunidad cerrada por diseño, limitada a la capacidad real de las GPUs compartidas. Cuando se llena, se abre lista de espera.", - "p4": "La comunidad vive en Discord. El sitio web solo gestiona acceso. Cada miembro paga por estar. Solo están quienes de verdad quieren construir." - }, - "server": { - "label": "// inference cluster", - "heading": "Cluster de inferencia", - "description": "Cluster compartido de inferencia. Modelos elegidos por la comunidad cada trimestre.", - "models": "// current models", - "comingSoon": "disponible pronto", - "guarantees": { - "0": "Tu código no entrena ningún modelo", - "1": "Datos en EU, sin logs de prompts", - "2": "Tokens ilimitados en modelos del cluster", - "3": "API key personal para cada miembro" - } - }, - "privacy": { - "label": "// privacy", - "heading": "Pura inferencia. Zero logs.", - "p1": "NaN es infraestructura de inferencia. No hay datos de registro de prompts, ni de las respuestas del modelo, nada. Todo vive en tu sesión local, en el cliente que sea, en el agente de turno, o en la app custom que te hagas.", - "p2": "La API es pura inferencia. Nada de lo que haces con ella queda registrado salvo métricas del propio servidor (tokens/s, RPM, etc.) por puro mantenimiento y medición del cluster.", - "p3": "Tu código no entrena ningún modelo. Tus prompts no se guardan. Tus datos no salen de tu sesión.", - "points": { - "noLogs": "Zero logs: no hay registro de prompts ni respuestas", - "localData": "Todo vive en tu sesión local del cliente o agente", - "euProcessing": "Procesamiento en la Unión Europea", - "noTraining": "Tu código no entrena ningún modelo", - "metrics": "Métricas de servidor solo para mantenimiento del cluster (tokens/s, RPM)" - } - }, - "modelVoting": { - "label": "// how models are chosen", - "heading": "Modelos elegidos por la comunidad. Renovados cada trimestre.", - "rules": { - "0": "Cada 3 meses, los miembros votan los nuevos modelos que correrán en el cluster.", - "1": "Los candidatos se preseleccionan técnicamente: tienen que caber en la capacidad del cluster.", - "2": "La votación se hace en Discord, abierta a todos los miembros.", - "3": "El ecosistema de modelos abiertos se mueve rápido. NaN también." - }, - "models": "// current models" - }, - "founder": { - "label": "// who's behind", - "heading": "La comunidad NaN la dirigen Cristian Córdova y Borja Perez.", - "bio": { - "0": "Founder de Helmcode, trabajando con startups en infraestructura cloud, AI tooling y software abierto.", - "1": "Gracias a Helmcode ofrecemos inferencia de modelos abiertos con planes sin límites de tokens para empresas, publico mis experimentos en redes sociales y estudios con IA." - }, - "quote": "Monté NaN porque quería poder correr modelos abiertos de los buenos sin límites de uso y además compartir y aprender con gente que está en la misma: builders que queman tokens de verdad.", - "links": { - "twitter": "Twitter", - "linkedin": "LinkedIn", - "github": "GitHub" - }, - "companyIntro": "La comunidad NaN existe gracias a Helmcode, la empresa detrás de toda la infraestructura de inferencia que alimenta la comunidad." - }, - "pricing": { - "label": "// pricing", - "subheading": "Un tier por región. Sin upsells. Sin trial.", - "plan1": { - "name": "NaN Member EU", - "flagLabel": "Europa", - "price": "70€", - "period": "/mes", - "taxNote": "IVA incluido" - }, - "plan2": { - "name": "NaN Member USA/Latam", - "flagLabel": "USA / Latam", - "price": "$75", - "period": "/mo", - "taxNote": "Impuestos incluidos" - }, - "plan3": { - "name": "NaN Community", - "flagLabel": "Comunidad", - "price": "$14.99", - "period": "/mes", - "taxNote": "Impuestos incluidos", - "badge": "// comunidad", - "noGpuTag": "Sin acceso a GPU · solo Discord" - }, - "limited": "Plazas limitadas", - "features": { - "0": "Acceso al cluster compartido — modelos abiertos sin caps de tokens", - "1": "DeepSeek V4-Flash (frontier 284B, contexto 1M, razonamiento avanzado) — 500M tokens/mes", - "2": "Modelos abiertos elegidos por la comunidad", - "3": "API key personal compatible con OpenAI", - "4": "Canales privados en Discord solo para miembros", - "5": "Voz en la votación trimestral de modelos", - "6": "Mes a mes, sin permanencia" - }, - "communityFeatures": { - "0": "Acceso a Discord", - "1": "Canales privados solo para miembros", - "2": "Charlas en directo, builds y AMAs", - "3": "Plaza reservada para acceso a inferencia" - }, - "cta": "Únete", - "communityCta": "Únete a la comunidad", - "footerNote": "Plazas limitadas. Cuando se llena, lista de espera.", - "communityFooterNote": "Sin permanencia. Cancela cuando quieras." - }, - "cta": { - "label": "// join", - "heading": "Una comunidad de builders. Plazas limitadas. GPUs compartidas sin límites de uso.", - "description": "Compartimos GPUs dedicadas para correr modelos abiertos y quemar tokens sin límites. Déjanos tu email y te escribimos cuando abramos pagos." - }, - "faq": { - "label": "// faq", - "heading": "Lo que probablemente vas a preguntar.", - "items": { - "0": { - "id": "what-is-nan", - "question": "¿Qué es NaN?", - "answer": "NaN es una comunidad cerrada de builders que comparten acceso a GPUs dedicadas para correr modelos de IA de código abierto. Cada miembro paga por estar, y solo hay espacio para los que realmente van a construir." - }, - "1": { - "id": "how-it-works", - "question": "¿Cómo funciona el cluster?", - "answer": "Cada miembro de la comunidad comparte un cluster de inferencia dedicado. Los modelos del cluster corren 24/7 y cada miembro recibe una API key personal compatible con OpenAI para usar los modelos a su gusto, sin caps de tokens." - }, - "2": { - "id": "token-limits", - "question": "¿Realmente no hay límites de tokens?", - "answer": "Puedes quemar tokens sin límites en los modelos más pequeños del cluster. Los modelos frontier (como DeepSeek V4-Flash) tienen una cuota mensual generosa por miembro para mantener el plan sostenible. No hay sorpresas en la factura: el precio fijo de la suscripción es lo único que pagas." - }, - "3": { - "id": "model-voting", - "question": "¿Cómo se eligen los modelos?", - "answer": "Cada trimestre, los miembros de la comunidad votan qué nuevos modelos correrán en el cluster. Los candidatos se preseleccionan técnicamente para asegurar que caben en la capacidad del cluster. La votación se hace en Discord, abierta a todos los miembros." - }, - "4": { - "id": "where-does-data-go", - "question": "¿Dónde van mis datos?", - "answer": "Los datos se procesan en la Unión Europea, sin logs de prompts. Tu API key es personal e intransferible. No compartimos datos entre miembros ni vendemos información a terceros. Tu código no entrena ningún modelo." - }, - "5": { - "id": "who-is-this-for", - "question": "¿Para quién es NaN?", - "answer": "Para builders que quieren correr modelos de IA en serio sin la sobrecarga de gestionar infraestructura. Si eres startup, investigador o maker y necesitas inferencia a escala sin límites de tokens, NaN es para ti. No es para quien solo quiere experimentar un par de veces." - }, - "6": { - "id": "why-open-models", - "question": "¿Por qué solo modelos abiertos?", - "answer": "Porque creemos que la innovación real viene de herramientas que puedes auditar, modificar y usar sin restricciones. Los modelos cerrados son cómodos pero crean dependencia. Con los abiertos tienes control total sobre tu stack." - }, - "7": { - "id": "can-i-train", - "question": "¿Puedo entrenar modelos?", - "answer": "No. NaN es para inferencia, no para entrenamiento. Las GPUs están optimizadas para correr modelos existentes a alta velocidad, no para fine-tuning. Si necesitas entrenamiento, hay otras plataformas para eso." - }, - "8": { - "id": "payment-methods", - "question": "¿Qué métodos de pago aceptan?", - "answer": "Actualmente aceptamos pagos por tarjeta de crédito/débito. Los precios incluyen impuestos (IVA para EU, taxes para USA/Latam). Puedes cancelar en cualquier momento, sin permanencia." - }, - "9": { - "id": "join-community", - "question": "¿Cómo entro a la comunidad?", - "answer": "Deja tu email en la lista de espera y te escribimos cuando abramos pagos. La comunidad vive en Discord — una vez que te unes, tienes acceso a todos los canales privados, votaciones trimestrales y soporte directo del equipo." - } - } - }, - "footer": { - "brand": "nan.builders" - }, - "waitlist": { - "registered": "// registered", - "interest": "// interest registered", - "followUp": "No enviamos newsletters. Solo escribiremos cuando abramos pagos.", - "confirmation": { - "label": "// confirmación", - "heading": "¿Seguro que quieres apuntarte?", - "p1": "Te estás apuntando a la waitlist de una comunidad privada de pago. Cuando abramos pagos, se requerirá una suscripción mensual para tener acceso a las GPUs compartidas y a los modelos que elija la comunidad.", - "p2": "Solo queremos que se apunten personas con intención real de pagar. Si solo tienes curiosidad, mejor no te apuntes.", - "cancel": "Cancelar", - "confirm": "Sí, apuntarme", - "submitting": "Enviando…" - }, - "honeypot": "Do not fill this field", - "email": { - "label": "email", - "placeholder": "tu@email.com" - }, - "region": { - "label": "región", - "default": "Selecciona tu región" - }, - "submit": { - "idle": "Únete a la waitlist", - "submitting": "Enviando…" - }, - "footer": "Sin spam. Sin newsletters. Solo te escribimos cuando abramos pagos." - }, - "waitlistHelpers": { - "regions": { - "europa": "Europa", - "latam": "LATAM", - "usa": "USA" - }, - "successInterest": "Anotado. Estamos disponibles para ${regionLabel}. Te avisamos en cuanto abramos pagos.", - "successRegistered": "Estás en la lista. Iremos aceptando miembros por orden de llegada cuando abramos pagos.", - "errors": { - "invalidEmail": "Email inválido. Revisa el formato.", - "invalidRegion": "Selecciona una región.", - "rateLimited": "Demasiados intentos. Espera un minuto.", - "networkError": "Sin conexión. Vuelve a intentarlo.", - "serverError": "Algo falló en el server. Vuelve a intentarlo en un momento." - } - }, "community": { "label": "// community", "pageTitle": "Comunidad NaN", @@ -427,5 +198,428 @@ "total": "Total", "winner": "🏆 Ganador" } + }, + "nan": { + "meta": { + "title": "NaN — Construye sin límites", + "siteName": "NaN", + "ogLocale": "es_ES", + "description": "Una comunidad de builders que comparten GPUs para correr modelos abiertos a tarifa fija. API compatible con OpenAI, cero logs, plazas limitadas por cómputo." + }, + "a11y": { + "skip": "Saltar al contenido", + "brandHome": "NaN, inicio", + "menuOpen": "Abrir menú", + "menuClose": "Cerrar menú", + "navPrimary": "Principal", + "navLegal": "Legal", + "navDocs": "Secciones de la documentación", + "language": "Idioma", + "email": "Email", + "region": "Región", + "close": "Cerrar", + "cookies": "Aviso de cookies", + "projectDetail": "Detalle del proyecto", + "openProject": "Abrir {name}", + "projectsRail": "Proyectos de la comunidad, carrusel horizontal", + "membersOnly": "{title}, solo para miembros", + "gpuDither": "NVIDIA RTX 5090, ilustración con trama", + "crt404": "Un ordenador CRT antiguo con una pantalla azul, una cara triste y el mensaje NO TOKENS" + }, + "nav": { + "models": "Modelos", + "community": "Comunidad", + "events": "Eventos", + "projects": "Proyectos", + "docs": "Docs", + "login": "Entrar" + }, + "hero": { + "line1": "Construye sin", + "line2": "límites.", + "sub": "No te quedes sin tokens. Nunca.", + "body": "NaN es la comunidad de builders que comparten GPUs para correr modelos de IA abiertos. Construye lo que necesites, sin límite de tokens. You can just do things.", + "spots": "Plazas limitadas", + "waitline": "Únete a la waitlist para activar tu suscripción. Solo deja tu email.", + "emailPlaceholder": "tu@email.dev", + "regionLabel": "Elige tu región", + "submit": "Únete a la waitlist", + "sending": "Enviando…", + "okRegistered": "Ya estás en la waitlist.", + "okPosition": "posición", + "okText": "Aprobaremos tu plaza en los próximos días. No pierdas de vista tu bandeja ^__^", + "errEmail": "Ese email no parece válido.", + "errRegion": "Elige una región.", + "errNetwork": "Ha fallado la red, no tú. Prueba otra vez.", + "errGeneric": "Algo se ha roto por nuestro lado. Prueba en un rato.", + "okInterest": "Anotado. Todavía no estamos abiertos en tu región, pero te escribimos en cuanto lo estemos.", + "errRateLimited": "Demasiados intentos. Espera un minuto." + }, + "why": { + "label": "qué es nan", + "title1": "Qué es NaN.", + "title2": "La comunidad de builders", + "lead": "Builders compartiendo GPUs dedicadas para correr modelos abiertos y quemar tokens sin límites.", + "p": [ + "Las APIs de modelos cerrados son cómodas, pero caras y limitadas. Los modelos abiertos son libres, pero correrlos en serio exige un hardware que casi nadie tiene en casa.", + "NaN lo resuelve compartiendo GPUs potentes entre builders que de verdad las usan, repartiendo el coste real de la infraestructura.", + "Una comunidad cerrada por diseño, limitada a la capacidad real de las GPUs compartidas. Cuando se llena, abrimos waitlist.", + "La comunidad vive en Discord. La web solo gestiona el acceso. Cada miembro paga por estar aquí. Solo entra quien de verdad quiere construir." + ] + }, + "built": { + "label": "hecho con nan", + "title1": "No quemamos tokens.", + "title2": "Construimos.", + "sub": "Proyectos reales hechos por miembros de NaN sobre el cluster de inferencia compartido. Esto es lo que pasa cuando los builders tienen hardware de verdad.", + "railLabel": "proyectos de la comunidad", + "seeAll": "Ver todos los proyectos", + "addYours": "Añade el tuyo" + }, + "models": { + "label": "el cluster", + "titleSuffix": "modelos.", + "titleLine2": "Una API.", + "sub": "Todo el stack de inferencia de modelos abiertos, votado por la comunidad cada trimestre. Los datos se quedan en la UE, sin registrar nada.", + "unmetered": "sin contador", + "cta": "Únete a la waitlist" + }, + "privacy": { + "label": "privacidad", + "title1": "Pura inferencia.", + "title2": "Cero logs.", + "intro": "NaN es infraestructura de inferencia. No se registran prompts, ni respuestas del modelo, nada. Todo vive en tu sesión local, en el cliente, agente o app que uses.", + "guarantees": [ + "Cero logs. Ni prompts ni respuestas quedan registrados.", + "Todo vive en tu cliente local o en la sesión de tu agente.", + "Procesado en la Unión Europea.", + "Tu código no entrena ningún modelo.", + "Solo métricas de servidor, para mantenimiento del cluster (tokens/s, RPM)." + ], + "termNote": "# 0 bytes. 0 filas. nada almacenado, por diseño.", + "termFoot": "saliendo del servidor: tokens/s · RPM · métricas del cluster" + }, + "pricing": { + "label": "membresía", + "title1": "Una comunidad.", + "title2": "Dos tiers.", + "sub": "La comunidad es el producto. La inferencia es el superpoder que le añades. Sin upsells, sin trial.", + "perVat": "/mes · IVA incluido", + "perTaxes": "/mes · impuestos incluidos", + "inferenceIncluded": "inferencia incluida", + "memberIncludes": [ + "Acceso al cluster compartido. Modelos abiertos, sin límite de tokens.", + "DeepSeek V4-Flash (frontier 284B, contexto 1M, razonamiento). 500M tokens/mes.", + "Modelos abiertos elegidos por la comunidad", + "API key personal compatible con OpenAI", + "Canales privados de Discord solo para miembros", + "Eventos, workshops y hackathons", + "Voto en la votación trimestral de modelos" + ], + "memberCond": "Mes a mes, sin compromiso. Plazas limitadas: cuando se llena, waitlist.", + "memberCta": "Únete a la waitlist", + "communityIncludes": [ + "Acceso a Discord", + "Canales solo para miembros", + "Charlas en directo, builds, AMAs", + "Eventos, workshops y hackathons", + "Un sitio reservado en la cola de inferencia" + ], + "communityCond": "Sin compromiso. Cancela cuando quieras.", + "communityCta": "Únete a la comunidad" + }, + "who": { + "label": "quién está detrás", + "title": "La comunidad NaN la lideran Cristian Córdova y Borja Perez.", + "quote": "Monté NaN porque quería correr modelos abiertos de los buenos sin límites de uso, y compartir y aprender con gente que está en la misma: builders que queman tokens de verdad.", + "quoteBy": "Cristian Córdova", + "cristianRole": "Founder de Helmcode", + "cristianLoc": "Tenerife, Islas Canarias", + "cristianBio": [ + "Founder de Helmcode, trabajando con startups en infraestructura cloud, AI tooling y open source.", + "Gracias a Helmcode ofrecemos inferencia de modelos abiertos con planes de tokens ilimitados para empresas. Comparto mis experimentos en redes y mi research de IA." + ], + "borjaRole": "Marketing Lead", + "borjaLoc": "Valencia, España", + "borjaBio": [ + "Builder con habilidades de marketing. Ayudo a Cristian con la comunicación y la comunidad.", + "Nuestra meta es crear la mejor comunidad de modelos abiertos del mundo, ayudando a la gente a crear y lanzar productos con IA abierta." + ] + }, + "faq": { + "label": "faq", + "title": "Lo que probablemente vas a preguntar.", + "items": [ + { + "q": "¿Qué es NaN?", + "a": "Una comunidad cerrada de builders que comparten GPUs dedicadas para correr modelos abiertos. Cada miembro paga por estar aquí, y solo hay sitio para gente que de verdad construye." + }, + { + "q": "¿Cómo funciona el cluster?", + "a": "Los modelos corren 24/7 en un cluster de inferencia compartido. Cada miembro recibe una API key personal compatible con OpenAI: cambias el base_url y ya estás dentro." + }, + { + "q": "¿De verdad son tokens ilimitados?", + "a": "En los modelos del cluster, sin contador. Los frontier (como DeepSeek V4-Flash) llevan 500M tokens/mes por miembro para que el plan se sostenga. Ese límite está publicado aquí, no en letra pequeña. La factura es el precio fijo de la suscripción y no se mueve." + }, + { + "q": "¿Cómo se eligen los modelos?", + "a": "Cada trimestre los miembros votan en Discord qué modelos nuevos entran al cluster. Los candidatos se preseleccionan técnicamente: tienen que caber en la capacidad real. El ecosistema abierto se mueve rápido, y el cluster también." + }, + { + "q": "¿A dónde van mis datos?", + "a": "Se procesan en la Unión Europea, sin registrar prompts ni respuestas. Tu API key es personal e intransferible. Tu código no entrena ningún modelo y tus datos nunca salen de tu sesión." + }, + { + "q": "¿Para quién es NaN?", + "a": "Para builders que quieren correr modelos abiertos en serio sin montar la infraestructura: indie hackers, makers, gente con agentes y side projects en producción. No es para quien solo quiere probar un par de prompts." + }, + { + "q": "¿Por qué solo modelos abiertos?", + "a": "Porque se pueden auditar, modificar y usar sin pedir permiso. Los cerrados son cómodos pero crean dependencia. Con modelos abiertos, el stack es tuyo." + }, + { + "q": "¿Puedo entrenar modelos?", + "a": "No. NaN es inferencia, no entrenamiento. Las GPUs están optimizadas para correr modelos existentes a alta velocidad, no para fine-tuning." + }, + { + "q": "¿Qué métodos de pago aceptáis?", + "a": "Tarjeta de crédito o débito. Los precios incluyen impuestos (IVA en la UE, impuestos en USA/Latam). Sin compromiso: cancela cuando quieras." + }, + { + "q": "¿Cómo entro?", + "a": "Deja tu email en la waitlist y te escribimos cuando se abra una plaza. Hay waitlist porque no hay más GPUs: cuando entra cómputo, entra gente." + } + ] + }, + "footer": { + "cta1": "Empieza a construir.", + "cta2": "Empieza a quemar tokens.", + "go": "Únete a la waitlist", + "privacy": "Privacidad", + "terms": "Términos", + "cookies": "Cookies", + "community": "Comunidad", + "notice": "Usamos solo cookies estrictamente necesarias para que la web funcione. Sin analytics, sin publicidad, sin tracking entre sitios, nunca.", + "copyPre": "NaN es la comunidad de inferencia de", + "copyRights": "Todos los derechos reservados.", + "copyFrom": "Desde Canarias hasta 🌎", + "colExplore": "explora", + "colLegal": "legal", + "colConnect": "conecta" + }, + "cookie": { + "title": "Purrr", + "line1": "No rastreamos cookies, pero...", + "q": "¿Te gustan los gatos?", + "yes": "Sí", + "yesIDo": "Sí, me encantan" + }, + "notFound": { + "metaTitle": "404 — NaN", + "eyebrow": "error 404 // sin tokens", + "title1": "Esta página se quedó", + "title2": "sin tokens.", + "body": "La dirección que escribiste no existe, o nunca existió. En cualquier caso, aquí no hay nada que renderizar. Tú, en cambio, sí encajas aquí.", + "back": "Volver al inicio", + "join": "Únete a la waitlist" + }, + "projects": { + "metaTitle": "Proyectos — NaN", + "metaDesc": "Lo que está construyendo la comunidad NaN.", + "eyebrow": "// proyectos", + "title": "Proyectos de la comunidad.", + "subPre": "Lo que está construyendo la comunidad NaN. Marca tus favoritos con una estrella en", + "filterAll": "todos", + "groupTop": "top proyectos", + "groupAll": "// todos los proyectos", + "openApp": "Abrir app ↗", + "viewRepo": "Ver repo ↗" + }, + "docs": { + "metaTitle": "Docs — NaN", + "metaDesc": "Conecta tus herramientas al cluster de NaN. API compatible con OpenAI.", + "eyebrow": "// docs", + "navIntro": "Introducción", + "navGetting": "Primeros pasos", + "navModels": "Modelos", + "navExamples": "Ejemplos", + "navSupport": "Soporte", + "title": "Bienvenido a NaN.", + "leadA": "Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster corre modelos abiertos con una API compatible con OpenAI. Si algo acepta un ", + "leadB": ", funciona con NaN.", + "apiKeyH": "Tu API key", + "apiKeyP1": "Para conseguir tu API key tienes que ser miembro de la comunidad NaN. Genérala desde los ajustes de tu usuario, en ", + "apiKeyStrong": "API Keys", + "apiKeyP2": ", en ", + "apiKeyP3": ". Es personal e intransferible.", + "rateLimitsH": "Rate limits", + "rpmK": "peticiones por minuto", + "parK": "máx. en paralelo", + "nextH": "Qué hacer ahora", + "next": [ + { + "t": "Primeros pasos", + "d": "Endpoint, auth y setup. Apunta cualquier cliente compatible con OpenAI al cluster." + }, + { + "t": "Modelos", + "d": "El catálogo completo, capacidades y límites." + }, + { + "t": "Ejemplos", + "d": "Snippets en Python, Node.js y curl." + }, + { + "t": "Soporte", + "d": "Pregunta en el canal #support de Discord." + } + ] + }, + "events": { + "metaTitle": "Eventos — NaN", + "metaDesc": "Workshops, eventos y hackathons dentro de la comunidad NaN.", + "heroEyebrow": "// eventos", + "heroTitle1": "Vive dentro", + "heroTitle2": "de la comunidad.", + "heroSub": "Los workshops, eventos y hackathons pasan dentro de NaN. Las grabaciones viven en la comunidad. Entras para verlas.", + "membersOnly": "solo miembros", + "loginToSee": "Entra para verlo", + "monthLabel": "este mes en nan", + "monthTitle": "Este mes en NaN.", + "monthSub": "Los próximos 30 días. La comunidad propone y vota la agenda en Discord.", + "monthLockTitle": "solo miembros", + "monthLockBody": "La agenda completa vive dentro de la comunidad.", + "weekdays": [ + "lun", + "mar", + "mié", + "jue", + "vie", + "sáb", + "dom" + ], + "monthEmpty": "Nada programado en los próximos 30 días todavía. La comunidad propone la agenda en Discord.", + "types": { + "workshop": "workshop", + "event": "evento", + "hackathon": "hackathon" + }, + "workshopsLabel": "workshops", + "workshopsTitle": "Workshops.", + "workshopsSub": "Sesiones prácticas sobre agentes, tooling y lanzar con modelos abiertos. Grabadas para miembros.", + "eventsLabel": "eventos", + "eventsTitle": "Eventos.", + "eventsSub": "Momentos de la comunidad: hitos, AMAs y quedadas.", + "hackathonsLabel": "hackathons", + "hackathonsTitle": "Hackathons.", + "hackathonsSub": "Semanas de build sobre el cluster. Equipos, un tema y un demo day.", + "watchNote": "entra para verlo", + "hackathon": { + "blurb": "7 días para construir con los modelos open source del cluster. Equipos de 4, equilibrados. Premio: 1 mes de NaN gratis.", + "when": "7-13 de junio · semana de build · domingo 14: Demo Day", + "allowed": "Modelos permitidos: Qwen 3.6 o Gemma 4. El proyecto tiene que ser open source. Tema libre.", + "statusLabel": "inscripción cerrada", + "statusBody": "La ventana de inscripción se ha cerrado. ¡Esperamos verte en el próximo hackathon de NaN!" + } + }, + "community": { + "metaTitle": "Comunidad — NaN", + "metaDesc": "NaN es el trampolín para builders que construyen con IA abierta. Build in public, en comunidad.", + "heroEyebrow": "// la comunidad de NaN", + "heroTitle": "Crear. Compartir. Ayudar.", + "heroSub": "NaN es el trampolín para builders que construyen con IA abierta. Aquí hacemos Build in Public: enseñas lo que haces, aprendes de los demás y lanzas acompañado.", + "ctaPrimary": "Únete a la comunidad", + "ctaSecondary": "Ver el roadmap", + "whatLabel": "qué pasa aquí", + "whatTitle": "Una comunidad para construir, no solo para quemar tokens", + "whatIntro": "Todo lo que montamos en la comunidad de NaN tiene un objetivo: que salgas con algo aprendido, algo construido o alguien nuevo con quien construir.", + "pillars": [ + { + "name": "Discord", + "body": "Donde vive la comunidad. Aquí pasan los eventos, los anuncios y las conversaciones de cada día: discusiones sobre modelos, papers que merecen la pena, dudas de despliegue y lo que estés construyendo.", + "freq": "siempre abierto · el punto de encuentro" + }, + { + "name": "Eventos, workshops y hackatones", + "body": "Formación práctica cada mes. Traemos a gente que sabe a dar sesiones sobre IA, open source y todo lo que rodea a construir con modelos abiertos. Y montamos hackatones para ponernos retos de construir productos.", + "freq": "1x mes formativos · 1x trimestre hackatón" + }, + { + "name": "Built with NaN", + "body": "La sección de proyectos de la comunidad. Ayudamos a los builders a dar visibilidad a lo que construyen: los mejores proyectos los potenciamos en redes con el altavoz de NaN y Helmcode.", + "freq": "tu proyecto, delante de más gente" + }, + { + "name": "Café Helmcode", + "body": "El evento en persona. Un café por diferentes ciudades para ponernos cara, hablar sin prisa y construir en comunidad. Porque hay conversaciones que solo pasan fuera de una pantalla.", + "freq": "ciudad a ciudad · nos vemos pronto" + } + ], + "progressLabel": "en marcha", + "progressTitle": "Lo que estamos cocinando", + "progressIntro": "Tres iniciativas que ya están en construcción.", + "inProgressTag": "in progress", + "initiatives": [ + { + "name": "NaN Combinator", + "body": "Nuestra incubadora para builders. Queremos que nazcan empresas de producto desde NaN, y vamos a ayudarles en todo lo posible: formaciones de ventas, producto, marketing y escalado. Cada 6 meses montaremos una pequeña cohorte para ayudar a construir y escalar sus ideas, con su Demo Day al final. El objetivo: que dentro de poco puedas decir que ese producto tan guay que usas se construyó en la comunidad." + }, + { + "name": "Ambassador Plan", + "body": "El plan de embajadores de NaN. Nuestra manera de recompensar a quienes difunden la palabra: te ayudamos a montar y alojar eventos en nombre de NaN. Los embajadores se seleccionan de entre la comunidad, por méritos. Si ya estás moviendo NaN por tu cuenta, esto va de darte gasolina." + }, + { + "name": "Proyecto colaborativo open source", + "body": "Un repo de NaN mantenido entre todos. Puede ser una librería útil, un benchmark en español de modelos abiertos o un dataset. Lo importante: que sea de la comunidad, que sirva al ecosistema y que tu nombre esté en los commits." + } + ], + "roadmapLabel": "roadmap", + "roadmapTitle": "Lo que viene", + "roadmapIntro": "Tenemos muchos planes, pero no todo se puede atacar a la vez. Así que vamos paso a paso, y en abierto.", + "membersLabel": "nan_members", + "roadmapColInitiative": "iniciativa", + "roadmapColStatus": "estado", + "roadmap": [ + { + "initiative": "1.000 miembros", + "detail": "el objetivo de este año", + "status": "in_progress" + }, + { + "initiative": "Ambassador program", + "detail": "piloto con alguien de la comunidad", + "status": "next" + }, + { + "initiative": "Eventos online periódicos", + "detail": "workshops, webinars, charlas; embajadores destripando el repo del momento, o AMAs con invitados externos (gente de Hugging Face, por ejemplo)", + "status": "in_progress" + }, + { + "initiative": "Evento presencial", + "detail": "si juntamos suficiente gente", + "status": "soon" + }, + { + "initiative": "Hackatones", + "detail": "1 por trimestre; algunos con partner y premios guays", + "status": "planned" + }, + { + "initiative": "Proyectos de la comunidad", + "detail": "destacar y lanzar proyectos por los canales de Helmcode; quizás incluso un Demo Day trimestral", + "status": "planned" + }, + { + "initiative": "Mini-retos", + "detail": "retos de fin de semana, batallas de modelos, rondas de 'kill the wrapper' clonando productos", + "status": "planned" + } + ], + "closeTitle": "Construye acompañado", + "closeText": "Si estás construyendo con IA abierta, o quieres empezar, este es tu sitio. Entra, preséntate y enséñanos lo que traes entre manos.", + "closeCta": "Únete a la comunidad" + } } } diff --git a/package-lock.json b/package-lock.json index 6a866b9..2c7857a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@astrojs/cloudflare": "^13.1.3", "@astrojs/mdx": "^5.0.6", "@astrojs/preact": "^5.0.2", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", @@ -1269,6 +1271,24 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/archivo": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz", + "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index 551a427..dbe7e58 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@astrojs/cloudflare": "^13.1.3", "@astrojs/mdx": "^5.0.6", "@astrojs/preact": "^5.0.2", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", diff --git a/public/brand/nan-isotipo-color.png b/public/brand/nan-isotipo-color.png new file mode 100644 index 0000000..4e62b96 Binary files /dev/null and b/public/brand/nan-isotipo-color.png differ diff --git a/public/brand/nan-isotipo-color.svg b/public/brand/nan-isotipo-color.svg new file mode 100644 index 0000000..992b60a --- /dev/null +++ b/public/brand/nan-isotipo-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/nan-isotipo-hex.svg b/public/brand/nan-isotipo-hex.svg new file mode 100644 index 0000000..449190b --- /dev/null +++ b/public/brand/nan-isotipo-hex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/brand/nan-isotipo-mono-blanco.png b/public/brand/nan-isotipo-mono-blanco.png new file mode 100644 index 0000000..4fc6f93 Binary files /dev/null and b/public/brand/nan-isotipo-mono-blanco.png differ diff --git a/public/brand/nan-isotipo-mono-blanco.svg b/public/brand/nan-isotipo-mono-blanco.svg new file mode 100644 index 0000000..3471f78 --- /dev/null +++ b/public/brand/nan-isotipo-mono-blanco.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/nan-isotipo-mono-negro.png b/public/brand/nan-isotipo-mono-negro.png new file mode 100644 index 0000000..10c10f3 Binary files /dev/null and b/public/brand/nan-isotipo-mono-negro.png differ diff --git a/public/brand/nan-isotipo-mono-negro.svg b/public/brand/nan-isotipo-mono-negro.svg new file mode 100644 index 0000000..3e7d5af --- /dev/null +++ b/public/brand/nan-isotipo-mono-negro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/nan-wordmark-black.svg b/public/brand/nan-wordmark-black.svg new file mode 100644 index 0000000..489af7a --- /dev/null +++ b/public/brand/nan-wordmark-black.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/brand/nan-wordmark-white.svg b/public/brand/nan-wordmark-white.svg new file mode 100644 index 0000000..1b024e5 --- /dev/null +++ b/public/brand/nan-wordmark-white.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/img/404-crt.webp b/public/img/404-crt.webp new file mode 100644 index 0000000..2d02215 Binary files /dev/null and b/public/img/404-crt.webp differ diff --git a/public/img/borja.webp b/public/img/borja.webp new file mode 100644 index 0000000..21b31ae Binary files /dev/null and b/public/img/borja.webp differ diff --git a/public/img/cat.png b/public/img/cat.png new file mode 100644 index 0000000..9a62148 Binary files /dev/null and b/public/img/cat.png differ diff --git a/public/img/events/hackathon-1.webp b/public/img/events/hackathon-1.webp new file mode 100644 index 0000000..85a199b Binary files /dev/null and b/public/img/events/hackathon-1.webp differ diff --git a/public/img/events/hermes.webp b/public/img/events/hermes.webp new file mode 100644 index 0000000..ac89427 Binary files /dev/null and b/public/img/events/hermes.webp differ diff --git a/public/img/events/opencode.webp b/public/img/events/opencode.webp new file mode 100644 index 0000000..d4b31ce Binary files /dev/null and b/public/img/events/opencode.webp differ diff --git a/public/img/events/pi-agent.webp b/public/img/events/pi-agent.webp new file mode 100644 index 0000000..976355f Binary files /dev/null and b/public/img/events/pi-agent.webp differ diff --git a/public/img/events/primer-mes.webp b/public/img/events/primer-mes.webp new file mode 100644 index 0000000..1f9906e Binary files /dev/null and b/public/img/events/primer-mes.webp differ diff --git a/public/img/founder.webp b/public/img/founder.webp new file mode 100644 index 0000000..baed41b Binary files /dev/null and b/public/img/founder.webp differ diff --git a/public/img/hero-dither.webp b/public/img/hero-dither.webp new file mode 100644 index 0000000..4c3d97c Binary files /dev/null and b/public/img/hero-dither.webp differ diff --git a/public/img/why-gpu-card.png b/public/img/why-gpu-card.png new file mode 100644 index 0000000..6b4cec0 Binary files /dev/null and b/public/img/why-gpu-card.png differ diff --git a/public/og/og-en.png b/public/og/og-en.png new file mode 100644 index 0000000..95ea9d8 Binary files /dev/null and b/public/og/og-en.png differ diff --git a/public/og/og-es.png b/public/og/og-es.png new file mode 100644 index 0000000..c626112 Binary files /dev/null and b/public/og/og-es.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..b4ee717 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://nan.builders/sitemap.xml diff --git a/scripts/gen-og.mjs b/scripts/gen-og.mjs new file mode 100644 index 0000000..bcf7cf9 --- /dev/null +++ b/scripts/gen-og.mjs @@ -0,0 +1,182 @@ +/** + * Genera las tarjetas Open Graph (1200x630) de nan.builders. + * + * node scripts/gen-og.mjs + * + * Salida: public/og/og-{en,es}.png (+ .svg autocontenido, por si hay que retocar) + * + * Sistema visual: off-black + glow radial violeta + wordmark vectorial + claim + * en Archivo con los MISMOS ejes que los H1 del sitio (wdth 118, wght 850, + * mayúsculas) + hairline violeta + dominio en JetBrains Mono. + * + * El texto se convierte a paths con fontkit, así que el PNG y el SVG no + * dependen de que la fuente esté instalada. Los TTF variables se descargan a + * scripts/.fontcache/ (ignorada por git) la primera vez; no se commitea ninguna + * fuente. + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as fontkit from 'fontkit'; +import sharp from 'sharp'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const cache = resolve(root, 'scripts/.fontcache'); + +// tokens (src/styles/tokens.css) +const BG = '#0B0B0C'; +const TEXT = '#F5F4F7'; +const VIOLET = '#7D39EB'; +const VIOLET_2 = '#9B6BF0'; +const MUTED = '#9A9A9E'; + +const W = 1200; +const H = 630; +const M = 80; // margen izquierdo + +const FONTS = { + archivo: { + file: 'Archivo.ttf', + url: 'https://raw.githubusercontent.com/google/fonts/main/ofl/archivo/Archivo%5Bwdth,wght%5D.ttf', + }, + mono: { + file: 'JetBrainsMono.ttf', + url: 'https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf', + }, +}; + +async function loadFont({ file, url }) { + mkdirSync(cache, { recursive: true }); + const path = resolve(cache, file); + if (!existsSync(path)) { + console.log(`bajando ${file}…`); + const res = await fetch(url); + if (!res.ok) throw new Error(`no se pudo bajar ${file}: ${res.status}`); + writeFileSync(path, Buffer.from(await res.arrayBuffer())); + } + return fontkit.openSync(path); +} + +/** + * Convierte una cadena en paths SVG con la tipografía y los ejes pedidos. + * Devuelve el ya posicionado y el ancho real, para poder encajarlo. + */ +function textToPaths(font, { text, variation, size, x, y, fill, tracking = 0 }) { + const inst = variation ? font.getVariation(variation) : font; + const run = inst.layout(text); + const upem = font.unitsPerEm; + const scale = size / upem; + const ls = tracking * upem; // tracking en em -> unidades de fuente + + let pen = 0; + const parts = []; + run.glyphs.forEach((glyph, i) => { + const pos = run.positions[i]; + const d = glyph.path.toSVG(); + if (d) { + const dx = pen + (pos.xOffset ?? 0); + const dy = pos.yOffset ?? 0; + parts.push(``); + } + pen += pos.xAdvance + ls; + }); + + // scale(s, -s) porque los contornos van con la Y hacia arriba + return { + svg: `${parts.join('')}`, + width: pen * scale, + }; +} + +/** Baja el tamaño hasta que el texto quepa en el ancho disponible. */ +function fitText(font, opts, maxWidth) { + let size = opts.size; + for (let i = 0; i < 40; i++) { + const out = textToPaths(font, { ...opts, size }); + if (out.width <= maxWidth) return { ...out, size }; + size -= 1; + } + return textToPaths(font, { ...opts, size }); +} + +/** Paths del wordmark, reescalados desde su viewBox 0 0 220 33. */ +function wordmark(x, y, width) { + const svg = readFileSync(resolve(root, 'public/brand/nan-wordmark-white.svg'), 'utf8'); + const inner = svg.replace(/^[\s\S]*?]*>/, '').replace(/<\/svg>\s*$/, ''); + const scale = width / 220; + return `${inner}`; +} + +const CARDS = { + en: { + claim: 'Build without limits.', + sub: 'A community of builders sharing GPUs to run open models at a flat rate.', + }, + es: { + claim: 'Construye sin límites.', + sub: 'Una comunidad de builders que comparten GPUs para correr modelos abiertos a tarifa fija.', + }, +}; + +const archivo = await loadFont(FONTS.archivo); +const mono = await loadFont(FONTS.mono); + +function card({ claim, sub }) { + const avail = W - M * 2; + + // claim: mismos ejes que los H1 del sitio + const claimG = fitText( + archivo, + { text: claim.toUpperCase(), variation: { wdth: 118, wght: 850 }, size: 76, x: M, y: 340, fill: TEXT, tracking: -0.02 }, + avail + ); + const subG = fitText( + archivo, + { text: sub, variation: { wdth: 100, wght: 400 }, size: 27, x: M, y: 402, fill: MUTED }, + avail + ); + const domainG = textToPaths(mono, { + text: 'nan.builders', + variation: { wght: 500 }, + size: 26, + x: M, + y: 536, + fill: VIOLET_2, + tracking: 0.02, + }); + + return ` + + + + + + + + + + + + + + + + ${wordmark(M, 96, 420)} + ${claimG.svg} + ${subG.svg} + + + + ${domainG.svg} +`; +} + +mkdirSync(resolve(root, 'public/og'), { recursive: true }); + +for (const [lang, copy] of Object.entries(CARDS)) { + const svg = card(copy); + const out = resolve(root, `public/og/og-${lang}.png`); + await sharp(Buffer.from(svg)).png({ compressionLevel: 9, palette: true }).toFile(out); + writeFileSync(resolve(root, `public/og/og-${lang}.svg`), svg); + console.log(`ok og-${lang}.png`); +} diff --git a/scripts/merge-ui-into-i18n.mjs b/scripts/merge-ui-into-i18n.mjs new file mode 100644 index 0000000..765ade7 --- /dev/null +++ b/scripts/merge-ui-into-i18n.mjs @@ -0,0 +1,45 @@ +/** + * Vuelca el diccionario del rediseño (nan-site/src/i18n/ui.ts) en los + * i18n/{en,es}.json de este repo. + * + * node scripts/merge-ui-into-i18n.mjs ../nan-site + * + * Todo el copy del rediseño cuelga de la clave `nan`, así que no pisa nada del + * copy vigente de este repo (que sigue sirviendo a los componentes aún no + * migrados). Los componentes nuevos leen t('nan.algo', locale). + * + * Es de un solo uso, pero se deja en el repo para poder repetir el volcado si + * hay que resincronizar durante la migración. + */ +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const source = resolve(process.argv[2] ?? '../nan-site', 'src/i18n/ui.ts'); + +// ui.ts es TypeScript, pero el objeto en sí es JSON puro: le quitamos los +// helpers y el `as const` y lo cargamos como módulo ESM. +let src = readFileSync(source, 'utf8'); +const start = src.indexOf('export const ui = '); +if (start === -1) throw new Error('no encuentro `export const ui =` en ' + source); +src = src.slice(start).replace(/^export const ui = /, 'const ui = '); +const end = src.indexOf('} as const;'); +if (end === -1) throw new Error('no encuentro el cierre `} as const;`'); +src = src.slice(0, end) + '};\nexport default ui;\n'; + +const tmp = join(mkdtempSync(join(tmpdir(), 'nan-ui-')), 'ui.mjs'); +writeFileSync(tmp, src); +const { default: ui } = await import(pathToFileURL(tmp).href); + +for (const loc of ['en', 'es']) { + const file = `i18n/${loc}.json`; + const target = JSON.parse(readFileSync(file, 'utf8')); + // Todo lo nuestro cuelga de `nan.*`: cero colisiones con el copy vigente y + // queda claro de quién es cada bloque. Cuando se borren los componentes + // viejos se puede aplanar. + target.nan = ui[loc]; + const added = Object.keys(ui[loc]); + writeFileSync(file, JSON.stringify(target, null, 2) + '\n'); + console.log(`${loc}: nan.* con ${added.length} bloques (${added.join(', ')})`); +} diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs new file mode 100644 index 0000000..8782737 --- /dev/null +++ b/scripts/optimize-images.mjs @@ -0,0 +1,99 @@ +/** + * Optimiza las imágenes de public/img: reescala al tamaño en que se muestran + * de verdad (x2 para pantallas densas) y convierte a WebP. + * + * node scripts/optimize-images.mjs # convierte y borra el original + * node scripts/optimize-images.mjs --dry # solo informa + * + * Se quedan fuera a propósito: + * - cat.png pixel art de 19KB, se pinta con image-rendering: pixelated + * - why-gpu-card ya pesa 24KB + * - brand/ y og/ vectores y tarjetas ya optimizadas + */ +import { readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'; +import { join, extname, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const dry = process.argv.includes('--dry'); + +// carpeta/fichero -> ancho máximo servido (ya x2 sobre el tamaño CSS) +const RULES = [ + { dir: 'public/img/projects', maxWidth: 760, quality: 82 }, // portada de disquete ~348px CSS + { dir: 'public/img/events', maxWidth: 1000, quality: 82 }, // tarjetas 16:9 en grid + { file: 'public/img/404-crt.jpg', maxWidth: 1400, quality: 82 }, + { file: 'public/img/founder.jpg', maxWidth: 480, quality: 85 }, + { file: 'public/img/borja.jpg', maxWidth: 480, quality: 85 }, + // fuente del dither del hero: el script la umbraliza a blanco y negro, así que + // los artefactos no se ven, pero subimos calidad por si acaso + { file: 'public/img/hero-dither.png', maxWidth: 1600, quality: 90 }, +]; + +const RASTER = new Set(['.png', '.jpg', '.jpeg']); +const kb = (n) => `${(n / 1024).toFixed(0)}KB`; + +const skipped = []; + +async function convert(abs, maxWidth, quality) { + const before = statSync(abs).size; + const out = abs.replace(/\.(png|jpe?g)$/i, '.webp'); + + let meta; + try { + meta = await sharp(abs).metadata(); + } catch { + // p.ej. un .ico renombrado a .png: se deja tal cual y se avisa + skipped.push(abs); + console.log(`skip ${abs.replace(root + '\\', '').replace(root + '/', '')} formato no soportado`); + return { before, after: before }; + } + const resize = meta.width && meta.width > maxWidth ? { width: maxWidth } : null; + + if (dry) { + console.log(`dry ${abs.replace(root + '\\', '').replace(root + '/', '')} ${kb(before)} ${meta.width}px${resize ? ` -> ${maxWidth}px` : ''}`); + return { before, after: before }; + } + + let pipe = sharp(abs); + if (resize) pipe = pipe.resize(resize); + await pipe.webp({ quality, effort: 6 }).toFile(out); + + const after = statSync(out).size; + unlinkSync(abs); // el original vive en el historial de git + console.log( + `ok ${out.replace(root + '\\', '').replace(root + '/', '')} ${kb(before)} -> ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)` + ); + return { before, after }; +} + +const targets = []; +for (const rule of RULES) { + if (rule.file) { + const abs = resolve(root, rule.file); + if (existsSync(abs)) targets.push({ abs, ...rule }); + continue; + } + const dir = resolve(root, rule.dir); + if (!existsSync(dir)) continue; + for (const name of readdirSync(dir)) { + if (RASTER.has(extname(name).toLowerCase())) targets.push({ abs: join(dir, name), ...rule }); + } +} + +let before = 0; +let after = 0; +for (const t of targets) { + const r = await convert(t.abs, t.maxWidth, t.quality); + before += r.before; + after += r.after; +} + +console.log( + `\n${targets.length} imágenes: ${(before / 1048576).toFixed(2)}MB -> ${(after / 1048576).toFixed(2)}MB` + + (dry ? ' (dry run)' : ` (-${Math.round((1 - after / before) * 100)}%)`) +); +if (skipped.length) { + console.log(`\n${skipped.length} sin convertir (revisar a mano):`); + for (const s of skipped) console.log(` ${s.replace(root + '\\', '').replace(root + '/', '')}`); +} diff --git a/src/components/community/ProjectsGallery.tsx b/src/components/community/ProjectsGallery.tsx deleted file mode 100644 index 7110dc2..0000000 --- a/src/components/community/ProjectsGallery.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { useState, useEffect } from 'preact/hooks'; - -export interface Project { - id: number; - handle: string; - name: string; - description: string; - imageUrl?: string; - appUrl?: string; - repoUrl?: string; - tags: string[]; - stars: number; -} - -const ALL_TAGS = ['agents', 'websites', 'ai', 'api', 'tools', 'automation', 'data', 'mcp', 'voice', 'vision']; - -const TAG_COLORS: Record = { - agents: 'bg-violet-500/15 text-violet-300 border-violet-800/60', - websites: 'bg-sky-500/15 text-sky-300 border-sky-800/60', - ai: 'bg-emerald-500/15 text-emerald-300 border-emerald-800/60', - api: 'bg-amber-500/15 text-amber-300 border-amber-800/60', - tools: 'bg-rose-500/15 text-rose-300 border-rose-800/60', - automation: 'bg-indigo-500/15 text-indigo-300 border-indigo-800/60', - data: 'bg-cyan-500/15 text-cyan-300 border-cyan-800/60', - mcp: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-800/60', - voice: 'bg-orange-500/15 text-orange-300 border-orange-800/60', - vision: 'bg-teal-500/15 text-teal-300 border-teal-800/60', -}; - -const medalStyle: Record = { - 0: { border: 'border-yellow-500/50', glow: 'shadow-[0_0_24px_rgba(234,179,8,0.12)]', badge: 'bg-neutral-900/90 text-yellow-300 border-yellow-500/70', label: '★ #1' }, - 1: { border: 'border-slate-400/40', glow: 'shadow-[0_0_16px_rgba(148,163,184,0.08)]', badge: 'bg-neutral-900/90 text-slate-300 border-slate-400/70', label: '★ #2' }, - 2: { border: 'border-amber-700/50', glow: 'shadow-[0_0_16px_rgba(180,83,9,0.10)]', badge: 'bg-neutral-900/90 text-amber-400 border-amber-600/70', label: '★ #3' }, - 3: { border: 'border-violet-500/30', glow: '', badge: 'bg-neutral-900/90 text-violet-400 border-violet-500/60', label: '★ #4' }, - 4: { border: 'border-violet-500/20', glow: '', badge: 'bg-neutral-900/90 text-violet-400 border-violet-500/50', label: '★ #5' }, -}; - -const safeUrl = (u?: string): string | null => (u && /^https?:\/\//i.test(u) ? u : null); - -function escapeRegex(s: string) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function Highlight({ text, query }: { text: string; query: string }) { - if (!query.trim()) return <>{text}; - const parts = text.split(new RegExp(`(${escapeRegex(query)})`, 'gi')); - return ( - <> - {parts.map((part, i) => - i % 2 === 1 ? ( - - {part} - - ) : ( - {part} - ), - )} - - ); -} - -function ProjectModal({ project, onClose }: { project: Project; onClose: () => void }) { - const app = safeUrl(project.appUrl); - const repo = safeUrl(project.repoUrl); - - useEffect(() => { - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; - document.addEventListener('keydown', handler); - document.body.style.overflow = 'hidden'; - return () => { - document.removeEventListener('keydown', handler); - document.body.style.overflow = ''; - }; - }, [onClose]); - - return ( -
- {/* Overlay */} -
- - {/* Panel */} -
e.stopPropagation()} - > - {/* Close */} - - - {/* Image */} - {safeUrl(project.imageUrl) && ( -
- {project.name} -
- )} - - {/* Content */} -
-
-

{project.name}

- - - - - {project.stars} - -
-

by {project.handle}

- -

- {project.description} -

- - {project.tags.length > 0 && ( -
- {project.tags.map((tag) => ( - - {tag} - - ))} -
- )} - - {(app || repo) && ( -
- {app && ( - - ↗ Open App - - )} - {repo && ( - - ↗ Repository - - )} -
- )} -
-
-
- ); -} - -function ProjectCard({ - project, - searchQuery, - rank, - onClick, -}: { - project: Project; - searchQuery: string; - rank?: number; - onClick: () => void; -}) { - const app = safeUrl(project.appUrl); - const repo = safeUrl(project.repoUrl); - const medal = rank !== undefined ? (medalStyle[rank] ?? medalStyle[4]) : null; - - return ( -
- {/* Image */} -
- {safeUrl(project.imageUrl) ? ( - {project.name} - ) : ( -
- - - -
- )} - {medal && ( - - {medal.label} - - )} - - - - - {project.stars} - -
- - {/* Body */} -
-

- -

-

by {project.handle}

-

- -

- - {project.tags.length > 0 && ( -
- {project.tags.map((tag) => ( - - {tag} - - ))} -
- )} - -
- {app && ( - e.stopPropagation()} - > - ↗ App - - )} - {repo && ( - e.stopPropagation()} - > - ↗ Repo - - )} - - click to expand - -
-
-
- ); -} - -interface Props { - projects: Project[]; -} - -export function ProjectsGallery({ projects }: Props) { - const [search, setSearch] = useState(''); - const [activeTag, setActiveTag] = useState(null); - const [selected, setSelected] = useState(null); - - const isFiltering = search.trim() !== '' || activeTag !== null; - - const filtered = isFiltering - ? projects.filter((p) => { - const q = search.trim().toLowerCase(); - const matchesSearch = - !q || - p.name.toLowerCase().includes(q) || - p.description.toLowerCase().includes(q); - const matchesTag = !activeTag || p.tags.includes(activeTag); - return matchesSearch && matchesTag; - }) - : projects; - - const featured = isFiltering ? [] : filtered.slice(0, 5); - const rest = isFiltering ? filtered : filtered.slice(5); - - return ( -
- {selected && setSelected(null)} />} - - {/* Search + Tag filter */} -
- setSearch((e.target as HTMLInputElement).value)} - placeholder="Search by name or description…" - class="w-full rounded-lg border border-neutral-800 bg-neutral-900/50 px-4 py-2.5 font-mono text-sm text-white placeholder-neutral-600 outline-none transition-colors focus:border-violet-500/50 sm:max-w-sm" - /> - -
- tags: - - {ALL_TAGS.map((tag) => ( - - ))} -
-
- - {/* Filtering: flat grid */} - {isFiltering && ( - <> - {filtered.length === 0 ? ( -
-

No projects match the current filter.

-
- ) : ( -
- {filtered.map((p) => ( - setSelected(p)} /> - ))} -
- )} - - )} - - {/* Default: top 5 + rest */} - {!isFiltering && ( - <> - {featured.length > 0 && ( -
-

★ Top Projects

-
- {featured.map((p, i) => ( - setSelected(p)} /> - ))} -
-
- )} - - {rest.length > 0 && ( -
-

All Projects

-
- {rest.map((p) => ( - setSelected(p)} /> - ))} -
-
- )} - - {projects.length === 0 && ( -
-

No projects yet.

-

- Be the first to submit one on{' '} - - cloud.nan.builders - . -

-
- )} - - )} -
- ); -} diff --git a/src/components/landing/About.astro b/src/components/landing/About.astro deleted file mode 100644 index afb2271..0000000 --- a/src/components/landing/About.astro +++ /dev/null @@ -1,38 +0,0 @@ ---- -import { t } from '../../lib/i18n'; - -const { locale = 'en' } = Astro.props; ---- - -
-
-
-

- {t('about.label', locale)} -

- -

- {t('about.heading', locale)} -

- -
-

{t('about.p1', locale)}

-

{t('about.p2', locale)}

-

{t('about.p3', locale)}

-

{t('about.p4', locale)}

-
-
-
- - diff --git a/src/components/landing/AnimatedBackground.astro b/src/components/landing/AnimatedBackground.astro deleted file mode 100644 index 4d2e5b6..0000000 --- a/src/components/landing/AnimatedBackground.astro +++ /dev/null @@ -1,214 +0,0 @@ ---- ---- - - - - diff --git a/src/components/landing/CommunityBuilds.astro b/src/components/landing/CommunityBuilds.astro deleted file mode 100644 index 04f4c04..0000000 --- a/src/components/landing/CommunityBuilds.astro +++ /dev/null @@ -1,163 +0,0 @@ ---- -import { env } from 'cloudflare:workers'; - -interface Project { - id: number; - handle: string; - name: string; - description: string; - imageUrl?: string; - appUrl?: string; - tags: string[]; - stars: number; -} - -const { locale = 'en' } = Astro.props; - -let projects: Project[] = []; -try { - const base = env.CLOUD_API_URL.replace(/\/$/, ''); - const res = await fetch(`${base}/api/projects?limit=100`, { - headers: { origin: 'https://nan.builders' }, - }); - if (res.ok) { - const data = await res.json() as { projects: Project[] }; - projects = (data.projects ?? []) - .sort((a, b) => b.stars - a.stars) - .slice(0, 3); - } -} catch {} - -const safeUrl = (u?: string): string | null => - u && /^https?:\/\//i.test(u) ? u : null; - -const TAG_COLORS: Record = { - agents: 'bg-violet-500/15 text-violet-300 border-violet-800/60', - websites: 'bg-sky-500/15 text-sky-300 border-sky-800/60', - ai: 'bg-emerald-500/15 text-emerald-300 border-emerald-800/60', - api: 'bg-amber-500/15 text-amber-300 border-amber-800/60', - tools: 'bg-rose-500/15 text-rose-300 border-rose-800/60', - automation: 'bg-indigo-500/15 text-indigo-300 border-indigo-800/60', - data: 'bg-cyan-500/15 text-cyan-300 border-cyan-800/60', - mcp: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-800/60', - voice: 'bg-orange-500/15 text-orange-300 border-orange-800/60', - vision: 'bg-teal-500/15 text-teal-300 border-teal-800/60', -}; ---- - -{projects.length > 0 && ( - <> -
-
-
- -
-

- {locale === 'en' ? '// community builds' : '// lo que construimos'} -

-

- {locale === 'en' - ? <>We don't burn tokens. We build. - : <>No quemamos tokens. Construimos. - } -

-

- {locale === 'en' - ? 'Real projects built by NaN members using the shared inference cluster. This is what happens when builders get serious hardware.' - : 'Proyectos reales construidos por miembros de NaN usando el cluster de inferencia compartido. Esto es lo que pasa cuando builders tienen hardware en serio.' - } -

-
- - - - {/* CTA */} - - -
-
- -)} - - diff --git a/src/components/landing/Cta.astro b/src/components/landing/Cta.astro deleted file mode 100644 index bdeacc4..0000000 --- a/src/components/landing/Cta.astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -import { t } from '../../lib/i18n'; -import WaitlistForm from './WaitlistForm.tsx'; -import { getTranslations, getRegionLabels } from './waitlistForm.helpers'; - -const { locale = 'en' } = Astro.props; -const translations = getTranslations(locale); -const regionLabels = getRegionLabels(locale); ---- - -
-
-
-

- {t('cta.label', locale)} -

-

- {t('cta.heading', locale)} -

-

- {t('cta.description', locale)} -

- -
- -
-
-
- - diff --git a/src/components/landing/Faq.astro b/src/components/landing/Faq.astro deleted file mode 100644 index d529910..0000000 --- a/src/components/landing/Faq.astro +++ /dev/null @@ -1,46 +0,0 @@ ---- -import { t, tObj } from '../../lib/i18n'; -import FaqAccordion from './FaqAccordion.tsx'; - -const { locale = 'en' } = Astro.props; - -const itemsRaw = tObj>('faq.items', locale); -const items = Object.values(itemsRaw); ---- - -
-
-
-

- {t('faq.label', locale)} -

- -

- {t('faq.heading', locale)} -

- - -
-
- -
-
- {t('footer.brand', locale)} - © {new Date().getFullYear()} -
-
- - diff --git a/src/components/landing/FaqAccordion.tsx b/src/components/landing/FaqAccordion.tsx deleted file mode 100644 index c6c2ba4..0000000 --- a/src/components/landing/FaqAccordion.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from 'preact/hooks'; - -export type FaqItem = { - id: string; - question: string; - answer: string; -}; - -interface Props { - items: FaqItem[]; -} - -export default function FaqAccordion({ items }: Props) { - const [openId, setOpenId] = useState(null); - - function toggle(id: string) { - setOpenId((current) => (current === id ? null : id)); - } - - return ( -
    - {items.map((item) => { - const isOpen = openId === item.id; - const panelId = `faq-panel-${item.id}`; - const buttonId = `faq-button-${item.id}`; - return ( -
  • - - -
  • - ); - })} -
- ); -} diff --git a/src/components/landing/Founder.astro b/src/components/landing/Founder.astro deleted file mode 100644 index 157f828..0000000 --- a/src/components/landing/Founder.astro +++ /dev/null @@ -1,150 +0,0 @@ ---- -import { t, tArr } from '../../lib/i18n'; - -const { locale = 'en' } = Astro.props; - -const bio = tArr('founder.bio', locale); - -const founders = [ - { - name: 'Cristian Córdova', - handle: '@barckcode', - location: 'Tenerife, Islas Canarias', - bio, - quote: t('founder.quote', locale), - links: [ - { label: t('founder.links.twitter', locale), href: 'https://twitter.com/barckcode' }, - { label: t('founder.links.linkedin', locale), href: 'https://www.linkedin.com/in/helmcode/' }, - { label: t('founder.links.github', locale), href: 'https://github.com/barckcode' }, - ], - image: '/founder.jpg', - alt: 'Cristian Córdova', - }, - { - name: 'Borja Perez', - handle: '@borjaperfra', - location: 'Valencia, Spain', - bio: [], - quote: '', - links: [ - { label: t('founder.links.twitter', locale), href: 'https://twitter.com/borjaperfra' }, - ], - image: '/borja.jpg', - alt: 'Borja Perez', - }, -]; ---- - -
-
-
-

- {t('founder.label', locale)} -

- -

- {t('founder.heading', locale)} -

- - {/* Company intro */} -

-

- - {/* Founders grid */} -
- { - founders.map((founder) => ( -
-
- {/* Avatar */} -
-
- - {founder.alt} -
-
- - {/* Info */} -
-
-

{founder.name}

- - {founder.handle} - -
-

- {founder.location} -

- - {/* Bio */} - {founder.bio.length > 0 && ( -
- {founder.bio.map((line) =>

)} -
- )} - - {/* Quote */} - {founder.quote && ( -
- {founder.quote} -
- )} - - {/* Links */} - {founder.links.length > 0 && ( - - )} -
-
-
- )) - } -
-
-
- - diff --git a/src/components/landing/Hero.astro b/src/components/landing/Hero.astro deleted file mode 100644 index 021b13c..0000000 --- a/src/components/landing/Hero.astro +++ /dev/null @@ -1,77 +0,0 @@ ---- -import { t } from '../../lib/i18n'; -import Button from '../ui/Button.astro'; - -interface Props { - locale?: string; -} - -const { locale = 'en' } = Astro.props; ---- - -
-
- -
-

- NaN -

- -

- {t('hero.subheadline', locale)} -

- -

- {t('hero.description', locale)} -

- -
- - - - - {t('hero.badge', locale)} -
- -
- - -
-
- - - {t('hero.scroll', locale)} - -
diff --git a/src/components/landing/LanguageSwitcher.astro b/src/components/landing/LanguageSwitcher.astro deleted file mode 100644 index d507231..0000000 --- a/src/components/landing/LanguageSwitcher.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const currentLang = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; -const targetLang = currentLang === 'en' ? 'es' : 'en'; -const label = currentLang === 'en' ? 'ES' : 'EN'; ---- - - - {label} - diff --git a/src/components/landing/LoginButton.astro b/src/components/landing/LoginButton.astro deleted file mode 100644 index 27bfde5..0000000 --- a/src/components/landing/LoginButton.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- -const label = 'Login'; ---- - - - {label} - diff --git a/src/components/landing/ModelVoting.astro b/src/components/landing/ModelVoting.astro deleted file mode 100644 index bebf6df..0000000 --- a/src/components/landing/ModelVoting.astro +++ /dev/null @@ -1,44 +0,0 @@ ---- -import { t, tArr } from '../../lib/i18n'; - -const { locale = 'en' } = Astro.props; - -const rules = tArr('modelVoting.rules', locale); ---- - -
-
-
-

- {t('modelVoting.label', locale)} -

- -

- {t('modelVoting.heading', locale)} -

- -
    - { - rules.map((rule) => ( -
  • - - {rule} -
  • - )) - } -
-
-
- - diff --git a/src/components/landing/Pricing.astro b/src/components/landing/Pricing.astro deleted file mode 100644 index dd76d9e..0000000 --- a/src/components/landing/Pricing.astro +++ /dev/null @@ -1,239 +0,0 @@ ---- -import { t, tArr } from '../../lib/i18n'; -import Button from '../ui/Button.astro'; - -const { locale = 'en' } = Astro.props; - -const features = tArr('pricing.features', locale); -const communityFeatures = tArr('pricing.communityFeatures', locale); - -interface Plan { - name: string; - flag: string; - flagLabel: string; - price: string; - period: string; - taxNote: string; - features: string[]; - ctaHref: string; - variant: 'inference' | 'community'; - badge?: string; - noGpuTag?: string; -} - -const plans: Plan[] = [ - { - name: t('pricing.plan1.name', locale), - flag: '🇪🇺', - flagLabel: t('pricing.plan1.flagLabel', locale), - price: t('pricing.plan1.price', locale), - period: t('pricing.plan1.period', locale), - taxNote: t('pricing.plan1.taxNote', locale), - features, - ctaHref: '#waitlist', - variant: 'inference', - }, - { - name: t('pricing.plan2.name', locale), - flag: '🇺🇸', - flagLabel: t('pricing.plan2.flagLabel', locale), - price: t('pricing.plan2.price', locale), - period: t('pricing.plan2.period', locale), - taxNote: t('pricing.plan2.taxNote', locale), - features, - ctaHref: '#waitlist', - variant: 'inference', - }, - { - name: t('pricing.plan3.name', locale), - flag: '💬', - flagLabel: t('pricing.plan3.flagLabel', locale), - price: t('pricing.plan3.price', locale), - period: t('pricing.plan3.period', locale), - taxNote: t('pricing.plan3.taxNote', locale), - features: communityFeatures, - ctaHref: locale === 'en' ? '/community?lang=en' : '/community', - variant: 'community', - badge: t('pricing.plan3.badge', locale), - noGpuTag: t('pricing.plan3.noGpuTag', locale), - }, -]; ---- - -
-
-
-

- {t('pricing.label', locale)} -

-

- {t('pricing.subheading', locale)} -

- -
- { - plans.map((plan) => ( -
-
- {plan.badge && ( -

- {plan.badge} -

- )} -
-

{plan.name}

- - {plan.flag} - -
- -
- - {plan.price} - - {plan.period} -
-

{plan.taxNote}

- {plan.variant === 'community' && plan.noGpuTag ? ( -

- {plan.noGpuTag} -

- ) : ( -

- {t('pricing.limited', locale)} -

- )} - -
    - {plan.features.map((feature) => ( -
  • -
  • - ))} -
- - - -

- {plan.variant === 'community' - ? t('pricing.communityFooterNote', locale) - : t('pricing.footerNote', locale)} -

-
-
- )) - } -
-
-
- - diff --git a/src/components/landing/Privacy.astro b/src/components/landing/Privacy.astro deleted file mode 100644 index 94bdd19..0000000 --- a/src/components/landing/Privacy.astro +++ /dev/null @@ -1,60 +0,0 @@ ---- -import { t } from '../../lib/i18n'; - -const { locale = 'en' } = Astro.props; ---- - -
-
-
-

- {t('privacy.label', locale)} -

- -

- {t('privacy.heading', locale)} -

- -
-

{t('privacy.p1', locale)}

-

{t('privacy.p2', locale)}

-

{t('privacy.p3', locale)}

- -
    -
  • - - {t('privacy.points.noLogs', locale)} -
  • -
  • - - {t('privacy.points.localData', locale)} -
  • -
  • - - {t('privacy.points.euProcessing', locale)} -
  • -
  • - - {t('privacy.points.noTraining', locale)} -
  • -
  • - - {t('privacy.points.metrics', locale)} -
  • -
-
-
-
- - diff --git a/src/components/landing/Server.astro b/src/components/landing/Server.astro deleted file mode 100644 index 5fdcd5e..0000000 --- a/src/components/landing/Server.astro +++ /dev/null @@ -1,136 +0,0 @@ ---- -import { t, tArr } from '../../lib/i18n'; - -const { locale = 'en' } = Astro.props; - -type Model = { - name: string; - role: string; - tag: string; - soon?: boolean; -}; - -const currentModels: Model[] = [ - { - name: 'deepseek-v4-flash', - role: 'LLM · 284B-21B MoE · FP8 · 1M context · Tool calling · Reasoning · 500M tokens/mes', - tag: 'llm', - }, - { - name: 'mimo-v2.5', - role: 'Omnimodal · 310B-15B MoE · FP8 · 1M context · Vision · Audio · Tool calling · Reasoning · 500M tokens/mes', - tag: 'llm', - }, - { - name: 'gemma4', - role: 'LLM · 26B-A4B MoE · FP8 · 256K context · Tool calling · Reasoning · Vision', - tag: 'llm', - }, - { - name: 'qwen3.6', - role: 'LLM · 35B-A3B MoE · FP8 · 256K context · Tool calling · Reasoning', - tag: 'llm', - }, - { - name: 'glm5.2', - role: 'LLM · ~753B MoE · FP8 · sparse attention · 256K context · Tool calling · Reasoning · coding agéntico', - tag: 'llm', - soon: true, - }, - { - name: 'qwen3-embedding', - role: 'Embeddings · 8B · 4096 dims · 100+ languages · MMTEB 70.58', - tag: 'embedding', - }, - { - name: 'rerank', - role: 'Reranker · Qwen3-Reranker-8B · BF16 · 100+ languages · cross-lingual · completes RAG stack', - tag: 'rerank', - }, - { - name: 'kokoro', - role: 'TTS · 82M params · 67 voices · <1s latency · ef_dora · em_alex', - tag: 'tts', - }, - { - name: 'whisper', - role: 'STT · large-v3 · CTranslate2 · ~3.2% WER Spanish · auto language detection', - tag: 'stt', - }, -]; - -const guarantees = tArr('server.guarantees', locale); ---- - -
-
-
-

- {t('server.label', locale)} -

- -

- {t('server.heading', locale)} -

-

- {t('server.description', locale)} -

- - -

- {t('server.models', locale)} -

-
- { - currentModels.map((model) => ( -
-
-

{model.name}

- - {model.tag} - -
- { - model.soon && ( - - {t('server.comingSoon', locale)} - - ) - } -

{model.role}

-
- )) - } -
- - -
    - { - guarantees.map((guarantee) => ( -
  • - - {guarantee} -
  • - )) - } -
-
-
- - diff --git a/src/components/landing/WaitlistForm.tsx b/src/components/landing/WaitlistForm.tsx deleted file mode 100644 index f1e20a6..0000000 --- a/src/components/landing/WaitlistForm.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useState } from 'preact/hooks'; -import type { TargetedSubmitEvent } from 'preact'; -import { - REGIONS, - isValidEmail, - isWaitlistRegion, - normalizeEmail, - parseWaitlistResponse, - successMessage, - errorMessage, - type WaitlistRegion, - type WaitlistSuccess, - type WaitlistError, - type WaitlistTranslations, -} from './waitlistForm.helpers'; - -type Status = - | { kind: 'idle' } - | { kind: 'submitting' } - | { kind: 'success'; data: WaitlistSuccess } - | { kind: 'error'; data: WaitlistError }; - -interface Props { - t: WaitlistTranslations; - regionLabels: Record; -} - -export default function WaitlistForm({ t, regionLabels }: Props) { - const [email, setEmail] = useState(''); - const [region, setRegion] = useState(''); - const [honeypot, setHoneypot] = useState(''); - const [status, setStatus] = useState({ kind: 'idle' }); - const [clientError, setClientError] = useState(null); - const [confirming, setConfirming] = useState(false); - - function onSubmit(e: TargetedSubmitEvent) { - e.preventDefault(); - setClientError(null); - - if (honeypot.trim() !== '') { - setStatus({ - kind: 'success', - data: { - ok: true, - position: 0, - total: 0, - status: 'registered', - region: 'EU', - }, - }); - return; - } - - if (!isValidEmail(email)) { - setClientError(t.errors.invalidEmail); - return; - } - if (!isWaitlistRegion(region)) { - setClientError(t.errors.invalidRegion); - return; - } - - setConfirming(true); - } - - async function performSubmit() { - if (!isWaitlistRegion(region)) return; - - setConfirming(false); - setStatus({ kind: 'submitting' }); - - try { - const response = await fetch('/api/waitlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: normalizeEmail(email), - region, - _hp: honeypot, - }), - }); - - let body: unknown = null; - try { - body = await response.json(); - } catch { - body = null; - } - - const parsed = parseWaitlistResponse(response.status, body); - if (parsed.ok) { - setStatus({ kind: 'success', data: parsed }); - } else { - setStatus({ kind: 'error', data: parsed }); - } - } catch { - setStatus({ - kind: 'error', - data: { ok: false, error: 'network_error' }, - }); - } - } - - if (status.kind === 'success') { - const { status: s } = status.data; - const pill = s === 'registered' ? t.registered : t.interest; - const showFollowup = s === 'registered'; - return ( -
-

- {pill} -

-

- {successMessage(status.data, t)} -

- {showFollowup && ( -

- {t.followUp} -

- )} -
- ); - } - - const submitting = status.kind === 'submitting'; - const serverError = status.kind === 'error' ? errorMessage(status.data, t) : null; - const displayError = clientError ?? serverError; - - return ( - <> - {confirming && ( - - )} -
- - -
-
- - setEmail((e.currentTarget as HTMLInputElement).value)} - placeholder={t.email.placeholder} - class="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors" - /> -
- -
- - -
- - {displayError && ( - - )} - - - -

- {t.footer} -

-
-
- - ); -} diff --git a/src/components/landing/waitlistForm.helpers.ts b/src/components/landing/waitlistForm.helpers.ts deleted file mode 100644 index 530079c..0000000 --- a/src/components/landing/waitlistForm.helpers.ts +++ /dev/null @@ -1,257 +0,0 @@ -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', - 'mail.com', -]); - -const BLOCKED_EMAIL_TLDS: readonly string[] = [ - 'test', - 'invalid', - 'localhost', - 'example', -]; - -function isBlockedEmailDomain(email: string): boolean { - const at = email.lastIndexOf('@'); - if (at === -1) return false; - const domain = email.slice(at + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - const lastDot = domain.lastIndexOf('.'); - const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); - return BLOCKED_EMAIL_TLDS.includes(tld); -} - -export const REGIONS = ['EU', 'LATAM', 'USA'] as const; -export type WaitlistRegion = (typeof REGIONS)[number]; - -export const REGION_LABELS: Record = { - EU: 'Europa', - LATAM: 'LATAM', - USA: 'USA', -}; - -export const REGION_LABELS_EN: Record = { - EU: 'Europe', - LATAM: 'LATAM', - USA: 'USA', -}; - -export function getRegionLabels(locale: string): Record { - return locale === 'en' ? REGION_LABELS_EN : REGION_LABELS; -} - -export interface WaitlistTranslations { - registered: string; - interest: string; - followUp: string; - confirmation: { - label: string; - heading: string; - p1: string; - p2: string; - cancel: string; - confirm: string; - submitting: string; - }; - honeypot: string; - email: { - label: string; - placeholder: string; - }; - region: { - label: string; - default: string; - }; - submit: { - idle: string; - submitting: string; - }; - footer: string; - errors: { - invalidEmail: string; - invalidRegion: string; - rateLimited: string; - networkError: string; - serverError: string; - }; -} - -export function getTranslations(locale: string): WaitlistTranslations { - if (locale === 'en') { - return { - registered: '// registered', - interest: '// interest registered', - followUp: "We don't send newsletters. We'll only write when we open payments.", - confirmation: { - label: '// confirmation', - heading: 'Are you sure you want to join?', - p1: "You're joining the waitlist for a paid private community. When we open payments, a monthly subscription will be required to access shared GPUs and the models chosen by the community.", - p2: "We only want people with real intention to pay to join. If you're just curious, better not join.", - cancel: 'Cancel', - confirm: 'Yes, sign me up', - submitting: 'Sending…', - }, - honeypot: 'Do not fill this field', - email: { - label: 'email', - placeholder: 'you@email.com', - }, - region: { - label: 'region', - default: 'Select your region', - }, - submit: { - idle: 'Join the waitlist', - submitting: 'Sending…', - }, - footer: 'No spam. No newsletters. We only write when we open payments.', - errors: { - invalidEmail: 'Invalid email. Check the format.', - invalidRegion: 'Select a region.', - rateLimited: 'Too many attempts. Wait a minute.', - networkError: 'No connection. Try again.', - serverError: 'Something went wrong on the server. Try again in a moment.', - }, - }; - } - return { - registered: '// registered', - interest: '// interest registered', - followUp: 'No enviamos newsletters. Solo escribiremos cuando abramos pagos.', - confirmation: { - label: '// confirmación', - heading: '¿Seguro que quieres apuntarte?', - p1: 'Te estás apuntando a la waitlist de una comunidad privada de pago. Cuando abramos pagos, se requerirá una suscripción mensual para tener acceso a las GPUs compartidas y a los modelos que elija la comunidad.', - p2: 'Solo queremos que se apunten personas con intención real de pagar. Si solo tienes curiosidad, mejor no te apuntes.', - cancel: 'Cancelar', - confirm: 'Sí, apuntarme', - submitting: 'Enviando…', - }, - honeypot: 'Do not fill this field', - email: { - label: 'email', - placeholder: 'tu@email.com', - }, - region: { - label: 'región', - default: 'Selecciona tu región', - }, - submit: { - idle: 'Únete a la waitlist', - submitting: 'Enviando…', - }, - footer: 'Sin spam. Sin newsletters. Solo te escribimos cuando abramos pagos.', - errors: { - invalidEmail: 'Email inválido. Revisa el formato.', - invalidRegion: 'Selecciona una región.', - rateLimited: 'Demasiados intentos. Espera un minuto.', - networkError: 'Sin conexión. Vuelve a intentarlo.', - serverError: 'Algo falló en el server. Vuelve a intentarlo en un momento.', - }, - }; -} - -export function normalizeEmail(value: string): string { - return value.trim().toLowerCase(); -} - -export function isValidEmail(value: string): boolean { - const email = normalizeEmail(value); - if (email.length === 0 || email.length > 254) return false; - if (!EMAIL_REGEX.test(email)) return false; - if (isBlockedEmailDomain(email)) return false; - return true; -} - -export function isWaitlistRegion(value: string): value is WaitlistRegion { - return (REGIONS as readonly string[]).includes(value); -} - -export type WaitlistStatus = 'registered' | 'interest'; - -export type WaitlistSuccess = { - ok: true; - position: number; - total: number; - status: WaitlistStatus; - region: WaitlistRegion; -}; - -export type WaitlistErrorCode = - | 'invalid_email' - | 'invalid_region' - | 'rate_limited' - | 'server_error' - | 'network_error'; - -export type WaitlistError = { - ok: false; - error: WaitlistErrorCode; -}; - -export type WaitlistResponse = WaitlistSuccess | WaitlistError; - -export function parseWaitlistResponse( - status: number, - body: unknown, -): WaitlistResponse { - if (typeof body !== 'object' || body === null) { - return { ok: false, error: 'server_error' }; - } - const data = body as Record; - - if (status === 200 && data.ok === true) { - const rawStatus = data.status; - const parsedStatus: WaitlistStatus = - rawStatus === 'interest' ? 'interest' : 'registered'; - const rawRegion = typeof data.region === 'string' ? data.region : 'EU'; - const region: WaitlistRegion = isWaitlistRegion(rawRegion) ? rawRegion : 'EU'; - return { - ok: true, - position: typeof data.position === 'number' ? data.position : 0, - total: typeof data.total === 'number' ? data.total : 0, - status: parsedStatus, - region, - }; - } - - const errorCode = typeof data.error === 'string' ? data.error : ''; - switch (errorCode) { - case 'invalid_email': - case 'invalid_region': - case 'rate_limited': - return { ok: false, error: errorCode }; - case 'server_error': - return { ok: false, error: 'server_error' }; - default: - return { ok: false, error: 'server_error' }; - } -} - -export function successMessage(result: WaitlistSuccess, _t: WaitlistTranslations): string { - if (result.status === 'interest') { - const regionLabel = REGION_LABELS[result.region]; - return `Anotado. Estamos disponibles para ${regionLabel}. Te avisamos en cuanto abramos pagos.`; - } - return 'Estás en la lista. Iremos aceptando miembros por orden de llegada cuando abramos pagos.'; -} - -export function errorMessage(result: WaitlistError, t: WaitlistTranslations): string { - switch (result.error) { - case 'invalid_email': - return t.errors.invalidEmail; - case 'invalid_region': - return t.errors.invalidRegion; - case 'rate_limited': - return t.errors.rateLimited; - case 'network_error': - return t.errors.networkError; - case 'server_error': - default: - return t.errors.serverError; - } -} diff --git a/src/components/nan/CookieBanner.astro b/src/components/nan/CookieBanner.astro new file mode 100644 index 0000000..e44b582 --- /dev/null +++ b/src/components/nan/CookieBanner.astro @@ -0,0 +1,137 @@ +--- +// Banner de cookies con guiño retro Win95. No rastreamos nada; es un chiste. +import { getLang, useT } from '../../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).cookie; +const a11y = useT(lang).a11y; +--- + + + + + + + diff --git a/src/components/nan/Floppy.astro b/src/components/nan/Floppy.astro new file mode 100644 index 0000000..1c67f6f --- /dev/null +++ b/src/components/nan/Floppy.astro @@ -0,0 +1,168 @@ +--- +// Disquete reutilizable (§7). variant 'rail' = ancho fijo para el carrusel; +// 'grid' = 100% de la celda. La banda del slug hace scramble (data-scramble-hover). +import { getLang, useT } from '../../lib/i18n'; + +interface Props { + project: { slug: string; nombre: string; autor: string; descripcion: string; tags: string[]; url: string; thumbnail?: string; cover?: boolean }; + index?: number; + variant?: 'rail' | 'grid'; +} +const { project: p, index = 0, variant = 'rail' } = Astro.props; +const a11y = useT(getLang(Astro.url)).a11y; + +const skins = [ + { body: 'var(--color-violet)', label: '#fff', ink: '#0B0B0C' }, + { body: 'var(--color-bone)', label: 'var(--color-ink)', ink: '#fff' }, + { body: 'var(--color-violet-2)', label: '#fff', ink: '#0B0B0C' }, + { body: 'var(--color-surface)', label: '#fff', ink: '#0B0B0C' }, +]; +const s = skins[index % skins.length]; + +/** + * No todos los proyectos traen un destino: si el miembro no publicó appUrl ni + * repoUrl válidos (o venían en http, que el filtro descarta), `url` es ''. + * + * Antes se emitía `` igualmente, que recarga la página mientras su + * aria-label promete abrir el proyecto. Sin destino esto deja de ser un enlace: + * ni etiqueta `a`, ni foco de teclado, ni aria-label que prometa nada. El + * disquete sigue viéndose y contando su información, que es lo que aporta. + */ +const hasLink = Boolean(p.url); +const Tag = hasLink ? 'a' : 'div'; +--- + +
+ {p.slug} + + + + + + + {p.cover && p.thumbnail ? ( + + + + ) : ( + + NaN·MF2-HD1.44 MB + + {p.nombre} + {p.descripcion} + + + {p.tags.slice(0, 2).join(' · ')} + + + + )} + +

@{p.autor}

+
+ + diff --git a/src/components/nan/Footer.astro b/src/components/nan/Footer.astro new file mode 100644 index 0000000..c69d6e4 --- /dev/null +++ b/src/components/nan/Footer.astro @@ -0,0 +1,119 @@ +--- +import { getLang, useT } from '../../lib/i18n'; + +const lang = getLang(Astro.url); +const t = useT(lang); +const tt = t.footer; +const home = lang === 'es' ? '/es' : '/'; +const p = (path: string) => (lang === 'es' ? `/es${path}` : path); +const year = new Date().getFullYear(); +--- + +
+ + diff --git a/src/components/nan/Nav.astro b/src/components/nan/Nav.astro new file mode 100644 index 0000000..3c59e57 --- /dev/null +++ b/src/components/nan/Nav.astro @@ -0,0 +1,203 @@ +--- +// Nav del sistema definitivo. Wordmark = logotipo SVG (mask para recolorear a +// violeta al hover). Enlaces mono con subrayado wipe. Login = botón secundario. +// En móvil (<=860px) los enlaces pasan a un panel desplegable (hamburguesa). +import { getLang, useT, switchLocalePath } from '../../lib/i18n'; + +const lang = getLang(Astro.url); +const t = useT(lang); +const tt = t.nav; +const a11y = t.a11y; +const home = lang === 'es' ? '/es' : '/'; +const p = (path: string) => (lang === 'es' ? `/es${path}` : path); +const pathname = Astro.url.pathname; +--- + + + + + + diff --git a/src/components/nan/StructuredData.astro b/src/components/nan/StructuredData.astro new file mode 100644 index 0000000..9640ccc --- /dev/null +++ b/src/components/nan/StructuredData.astro @@ -0,0 +1,71 @@ +--- +/** + * JSON-LD del sitio. Lo incluye cada página que lo necesita (la home y /events). + * - Organization: siempre (identifica la marca ante buscadores y redes). + * - FAQPage: solo en la home, con las MISMAS preguntas que renderiza el FAQ. + * - ItemList de eventos: solo en /events, a partir de los datos reales. + * Regla del proyecto: datos reales o nada. Todo sale del diccionario + * (`i18n/{en,es}.json`, clave `nan`, vía useT) y de data/*.json. + */ +import { useT, type Locale } from '../../lib/i18n'; +import eventos from '../../data/eventos.json'; + +interface Props { + lang: Locale; + page: 'home' | 'events' | 'other'; + canonical: string; + origin: string; +} +const { lang, page, canonical, origin } = Astro.props; +const t = useT(lang); + +const organization = { + '@type': 'Organization', + '@id': `${origin}/#organization`, + name: 'NaN', + alternateName: 'nan.builders', + url: `${origin}/`, + logo: `${origin}/brand/nan-isotipo-color.png`, + description: t.meta.description, + parentOrganization: { '@type': 'Organization', name: 'Helmcode', url: 'https://helmcode.com' }, +}; + +const graph: Record[] = [organization]; + +if (page === 'home') { + graph.push({ + '@type': 'FAQPage', + '@id': `${canonical}#faq`, + mainEntity: t.faq.items.map((item) => ({ + '@type': 'Question', + name: item.q, + acceptedAnswer: { '@type': 'Answer', text: item.a }, + })), + }); +} + +if (page === 'events') { + // Solo lo verificable: título, duración y que son de la comunidad NaN. + const all = [...eventos.workshops, ...eventos.events]; + graph.push({ + '@type': 'ItemList', + '@id': `${canonical}#list`, + name: t.events.metaTitle, + itemListElement: all.map((v, i) => ({ + '@type': 'ListItem', + position: i + 1, + item: { + '@type': 'VideoObject', + name: v.title, + thumbnailUrl: `${origin}${v.thumb}`, + uploadDate: v.date, + publisher: { '@id': `${origin}/#organization` }, + }, + })), + }); +} + +const jsonld = { '@context': 'https://schema.org', '@graph': graph }; +--- + + diff --git a/src/components/nan/home/Faq.astro b/src/components/nan/home/Faq.astro new file mode 100644 index 0000000..1a24402 --- /dev/null +++ b/src/components/nan/home/Faq.astro @@ -0,0 +1,82 @@ +--- +import { getLang, useT } from '../../../lib/i18n'; + +const tt = useT(getLang(Astro.url)).faq; +const faqs = tt.items; +--- + +
+
+
+
+ 07{tt.label} +
+

{tt.title}

+
+ +
+ {faqs.map((item) => ( +
+ + {item.q} + + +

{item.a}

+
+ ))} +
+
+
+ + diff --git a/src/components/nan/home/Hero.astro b/src/components/nan/home/Hero.astro new file mode 100644 index 0000000..5aa33f1 --- /dev/null +++ b/src/components/nan/home/Hero.astro @@ -0,0 +1,234 @@ +--- +// Hero del sistema definitivo. Ilustración dithered en canvas (blanco->violeta +// al hover), glitch en el H1, formulario waitlist cableado (data-waitlist). +import { getLang, useT } from '../../../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).hero; +const a11y = useT(lang).a11y; + +// Las capas de glitch del H1 son pseudoelementos, así que su texto tiene que +// viajar por CSS. Se lo pasamos como custom property para que siga al idioma +// (antes iba hardcodeado en inglés y se veía sobre el titular español). +const glitchText = `'${`${tt.line1}\\A ${tt.line2}`.replace(/'/g, "\\'")}'`; + +// Mensajes de estado del formulario: los lee waitlist.ts desde el
. +const waitlistMsgs = JSON.stringify({ + sending: tt.sending, + okRegistered: tt.okRegistered, + okInterest: tt.okInterest, + okPosition: tt.okPosition, + okText: tt.okText, + errEmail: tt.errEmail, + errRegion: tt.errRegion, + errRateLimited: tt.errRateLimited, + errNetwork: tt.errNetwork, + errGeneric: tt.errGeneric, +}); +--- + +
+ + +
+
+

+ {tt.line1}
{tt.line2} +

+ +

{tt.sub}

+ +

{tt.body}

+ +

+ {tt.spots} + {tt.waitline} +

+ + + + + +
+ +
+ +
+ {tt.regionLabel} + + + +
+ + + +

+ +
+
+
+ + + + diff --git a/src/components/nan/home/Models.astro b/src/components/nan/home/Models.astro new file mode 100644 index 0000000..9b7f3a5 --- /dev/null +++ b/src/components/nan/home/Models.astro @@ -0,0 +1,159 @@ +--- +import modelos from '../../../data/modelos.json'; +import { getLang, useT } from '../../../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).models; +const home = lang === 'es' ? '/es' : '/'; + +// la data es la fuente de verdad (ES); en EN traducimos la cuota al vuelo +const quota = (c: string) => + lang === 'es' ? c : c.replace('sin contador', 'unmetered').replace('/mes', ' / mo'); +const total = modelos.categorias.reduce((n, c) => n + c.modelos.length, 0); +--- + +
+
+
+
+ 03{tt.label} +
+

{total} {tt.titleSuffix}
{tt.titleLine2}

+

{tt.sub}

+
+ +
+ {modelos.categorias.map((cat) => ( +
+
// {cat.label}
+ {cat.modelos.map((m) => ( +
+
+
+ + {m.id} + + {m.mostUsed && most used} + {m.frontier && frontier} + +
+ {m.by} +
+
+

{m.specs}

+ + {quota(m.cuota)} + +
+
+ ))} +
+ ))} +
+ +
+ {tt.cta} +
+
+
+ + diff --git a/src/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro new file mode 100644 index 0000000..19201a3 --- /dev/null +++ b/src/components/nan/home/Pricing.astro @@ -0,0 +1,214 @@ +--- +// Datos verificados contra nan.builders: Member EU 70€ · Member USA/Latam $75 · Community $14.99. +import { getLang, useT } from '../../../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).pricing; +const home = lang === 'es' ? '/es' : '/'; + +const tiers = [ + { + name: 'nan_member · eu', + amount: '70€', + per: tt.perVat, + badge: tt.inferenceIncluded, + ref: true, + includes: tt.memberIncludes, + cond: tt.memberCond, + cta: tt.memberCta, + href: `${home}#waitlist`, + primary: true, + }, + { + name: 'nan_member · usa / latam', + amount: '$75', + per: tt.perTaxes, + badge: tt.inferenceIncluded, + ref: true, + includes: tt.memberIncludes, + cond: tt.memberCond, + cta: tt.memberCta, + href: `${home}#waitlist`, + primary: true, + }, + { + name: 'nan_community', + amount: '$14.99', + per: tt.perTaxes, + badge: null, + ref: false, + includes: tt.communityIncludes, + cond: tt.communityCond, + cta: tt.communityCta, + href: 'https://cloud.nan.builders/', + primary: false, + }, +]; +--- + +
+
+
+
+ 05{tt.label} +
+

{tt.title1}
{tt.title2}

+

{tt.sub}

+
+ +
+ {tiers.map((tier) => ( +
+
+ {tier.name} + {tier.badge + ? {tier.badge} + : } +
+ +
+ {tier.amount} + {tier.per} +
+ +
    + {tier.includes.map((item) =>
  • {item}
  • )} +
+ + + {tier.cta} + +

{tier.cond}

+
+ ))} +
+
+
+ + diff --git a/src/components/nan/home/Privacy.astro b/src/components/nan/home/Privacy.astro new file mode 100644 index 0000000..9c98657 --- /dev/null +++ b/src/components/nan/home/Privacy.astro @@ -0,0 +1,136 @@ +--- +import { getLang, useT } from '../../../lib/i18n'; + +const tt = useT(getLang(Astro.url)).privacy; +--- + +
+
+
+ 04{tt.label} +
+

{tt.title1}
{tt.title2}

+
+
+

{tt.intro}

+ +
    + {tt.guarantees.map((g) =>
  • {g}
  • )} +
+
+ + +
+
+
+ + diff --git a/src/components/nan/home/WhoBehind.astro b/src/components/nan/home/WhoBehind.astro new file mode 100644 index 0000000..ee2b3f4 --- /dev/null +++ b/src/components/nan/home/WhoBehind.astro @@ -0,0 +1,157 @@ +--- +// Fotos de producción, sin dither por ahora. +import { getLang, useT } from '../../../lib/i18n'; + +const tt = useT(getLang(Astro.url)).who; +--- + +
+
+
+
+ 06{tt.label} +
+

{tt.title}

+
+ +
+
+

{tt.quote}

+ {tt.quoteBy} +
+ +
+
+
+ Cristian Córdova +
+
+

Cristian Córdova

+

+ @barckcode + · {tt.cristianLoc} +

+

{tt.cristianRole}

+
+ {tt.cristianBio.map((para) =>

{para}

)} +
+ +
+
+ +
+
+ Borja Perez +
+
+

Borja Perez

+

+ @borjaperfra + · {tt.borjaLoc} +

+

{tt.borjaRole}

+
+ {tt.borjaBio.map((para) =>

{para}

)} +
+ +
+
+
+
+
+
+ + diff --git a/src/components/nan/home/WhyNan.astro b/src/components/nan/home/WhyNan.astro new file mode 100644 index 0000000..25ab168 --- /dev/null +++ b/src/components/nan/home/WhyNan.astro @@ -0,0 +1,80 @@ +--- +// "What is NaN" — editorial. Eyebrow encima; debajo, dos columnas que arrancan +// a la misma altura: H2 + tarjeta dithered (izq) y texto (der). +import { getLang, useT } from '../../../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).why; +const a11y = useT(lang).a11y; +--- + +
+
+
+ 01{tt.label} +
+ +
+
+

{tt.title1}
{tt.title2}

+
+ +
+
+ +
+

{tt.lead}

+ {tt.p.map((para) =>

{para}

)} +
+
+
+
+ + + + diff --git a/src/components/ui/Button.astro b/src/components/ui/Button.astro deleted file mode 100644 index 378cfd7..0000000 --- a/src/components/ui/Button.astro +++ /dev/null @@ -1,38 +0,0 @@ ---- -interface Props { - href: string; - variant?: 'primary' | 'secondary'; - size?: 'sm' | 'md'; - class?: string; - 'aria-label'?: string; -} - -const { - href, - variant = 'primary', - size = 'md', - class: className = '', - 'aria-label': ariaLabel, -} = Astro.props; - -const baseClasses = - 'inline-flex items-center justify-center font-mono rounded-lg transition-all duration-300 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0a0a]'; - -const variants = { - primary: 'bg-violet-600 text-white hover:bg-violet-500 hover:shadow-[0_0_24px_rgba(139,92,246,0.4)]', - secondary: 'border border-neutral-700 text-neutral-400 hover:border-violet-500/50 hover:text-white hover:shadow-[0_0_16px_rgba(139,92,246,0.15)]', -}; - -const sizes = { - sm: 'text-xs px-5 py-2.5', - md: 'text-sm px-8 py-3', -}; ---- - - - - diff --git a/src/data/eventos.json b/src/data/eventos.json new file mode 100644 index 0000000..86e02bf --- /dev/null +++ b/src/data/eventos.json @@ -0,0 +1,16 @@ +{ + "agenda": [ + { "date": "2026-07-31", "type": "workshop", "title": { "en": "Agent evaluation and tracing", "es": "Evaluación y trazabilidad de agentes" }, "by": "@Jasonjimnz" } + ], + "workshops": [ + { "title": "Workshop: Opencode", "date": "24 jul 2026", "duration": "1:03:11", "thumb": "/img/events/opencode.webp", "tags": ["opencode", "coding agent", "dev tools"] }, + { "title": "Workshop: Pi Agent", "date": "15 jun 2026", "duration": "1:00:00", "thumb": "/img/events/pi-agent.webp", "tags": ["agents", "workshop"] }, + { "title": "Workshop: Hermes", "date": "12 may 2026", "duration": "1:31:27", "thumb": "/img/events/hermes.webp", "tags": ["agents"] } + ], + "events": [ + { "title": "Primer mes de NaN", "date": "24 may 2026", "duration": "1:14:39", "thumb": "/img/events/primer-mes.webp", "tags": ["general"] } + ], + "hackathons": [ + { "n": "#1", "title": "NaN Hackathon", "date": "15 jun 2026", "duration": "57:37", "thumb": "/img/events/hackathon-1.webp", "tags": ["hackathon", "qwen", "gemma"] } + ] +} diff --git a/src/data/modelos.json b/src/data/modelos.json new file mode 100644 index 0000000..1b1d56d --- /dev/null +++ b/src/data/modelos.json @@ -0,0 +1,94 @@ +{ + "nota": "Fuente de verdad técnica: esta página. Verificar contra el cluster antes de tocar cifras.", + "nodo": { + "gpu": "NVIDIA RTX PRO 6000 Blackwell", + "vram": "96GB VRAM por nodo", + "region": "EU", + "logs": "zero logs" + }, + "categorias": [ + { + "id": "llm", + "label": "llm", + "modelos": [ + { + "id": "deepseek-v4-flash", + "by": "DeepSeek", + "specs": "284B-21B MoE · FP8 · 1M context · tool calling · reasoning", + "cuota": "500M tokens/mes", + "frontier": true, + "mostUsed": true + }, + { + "id": "mimo-v2.5", + "by": "Xiaomi", + "specs": "omnimodal · 310B-15B MoE · FP8 · 1M context · vision · audio · tool calling · reasoning", + "cuota": "500M tokens/mes", + "frontier": true, + "mostUsed": true + }, + { + "id": "gemma4", + "by": "Google", + "specs": "26B-A4B MoE · FP8 · 256K context · tool calling · reasoning · vision", + "cuota": "sin contador", + "frontier": false + }, + { + "id": "qwen3.6", + "by": "Alibaba", + "specs": "35B-A3B MoE · FP8 · 256K context · tool calling · reasoning", + "cuota": "sin contador", + "frontier": false, + "mostUsed": true + } + ] + }, + { + "id": "embedding", + "label": "embedding", + "modelos": [ + { + "id": "qwen3-embedding", + "by": "Alibaba", + "specs": "8B · 4096 dims · 100+ languages · MMTEB 70.58", + "cuota": "sin contador", + "frontier": false + } + ] + }, + { + "id": "rerank", + "label": "rerank", + "modelos": [ + { + "id": "qwen3-reranker", + "by": "Alibaba", + "specs": "8B · BF16 · 100+ languages · cross-lingual", + "cuota": "sin contador", + "frontier": false + } + ] + }, + { + "id": "voz", + "label": "tts / stt", + "modelos": [ + { + "id": "kokoro", + "by": "Hexgrad", + "specs": "TTS · 82M params · 67 voces · <1s latency · ef_dora · em_alex", + "cuota": "sin contador", + "frontier": false + }, + { + "id": "whisper", + "by": "OpenAI", + "specs": "STT · large-v3 · CTranslate2 · ~3.2% WER Spanish · auto language detection", + "cuota": "sin contador", + "frontier": false + } + ] + } + ] +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro deleted file mode 100644 index c0c65b2..0000000 --- a/src/layouts/Base.astro +++ /dev/null @@ -1,89 +0,0 @@ ---- -import '../styles/global.css'; -import AnimatedBackground from '../components/landing/AnimatedBackground.astro'; -import LanguageSwitcher from '../components/landing/LanguageSwitcher.astro'; -import LoginButton from '../components/landing/LoginButton.astro'; -import Hero from '../components/landing/Hero.astro'; -import About from '../components/landing/About.astro'; -import CommunityBuilds from '../components/landing/CommunityBuilds.astro'; -import Server from '../components/landing/Server.astro'; -import Privacy from '../components/landing/Privacy.astro'; -import ModelVoting from '../components/landing/ModelVoting.astro'; -import Founder from '../components/landing/Founder.astro'; -import Pricing from '../components/landing/Pricing.astro'; -import Faq from '../components/landing/Faq.astro'; -import Cta from '../components/landing/Cta.astro'; - -interface Props { - title?: string; - description?: string; -} - -const { - title = 'NaN. Shared inference cluster. Open models. Community of builders.', - description = 'Builders sharing an inference cluster to run open models without token limits. OpenAI-compatible API, your code does not train anything.', -} = Astro.props; - -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; - -const siteUrl = 'https://nan.builders'; ---- - - - - - - - - - - - - - - - - - - - - {title} - - - - - -
- - - - - - - - - - -
- - diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index 27e6dbf..2a37b4d 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -1,5 +1,13 @@ --- import '../styles/global.css'; + +// Fuentes autohospedadas, igual que NanBase. Antes venían de Google Fonts: eso +// bloqueaba el render y mandaba la IP de cada visitante a un tercero. +import '@fontsource-variable/archivo/wdth.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; +import '@fontsource/jetbrains-mono/700.css'; + import { getCollection } from 'astro:content'; interface Props { @@ -7,10 +15,18 @@ interface Props { description?: string; } -const { title, description = 'Documentación de NaN — Conecta a nuestros modelos abiertos via API OpenAI-compatible.' } = Astro.props; +// La documentación existe solo en inglés (la colección MDX no está traducida), +// así que el idioma de la página, el copy por defecto y la tarjeta social lo son. +const { title, description = 'NaN docs. Connect to our open models through an OpenAI-compatible API.' } = Astro.props; const siteUrl = 'https://nan.builders'; +// Sin barra final, igual que el canonical que emite NanBase, para que las dos +// variantes de cada URL no compitan entre sí. +const canonical = `${siteUrl}${Astro.url.pathname.replace(/\/+$/, '') || '/'}`; +const ogImage = `${siteUrl}/og/og-en.png`; +const socialTitle = `${title} — NaN Docs`; + const entries = await getCollection('docs'); entries.sort((a, b) => a.data.order - b.data.order); @@ -43,30 +59,33 @@ const breadcrumbSegments = pathParts.map((part, i) => { --- - + - - - - + + + - + + - - - + + + + + + + + + - {title} — NaN Docs + {socialTitle} @@ -226,7 +245,8 @@ const breadcrumbSegments = pathParts.map((part, i) => { // Build TOC from h2/h3 headings const tocContainer = document.getElementById('toc'); - const headings = document.querySelectorAll('.docs-content h2, .docs-content h3'); + // HTMLElement (no Element) para poder leer dataset.tocText más abajo. + const headings = document.querySelectorAll('.docs-content h2, .docs-content h3'); if (headings.length > 0 && tocContainer) { headings.forEach((heading) => { diff --git a/src/layouts/NanBase.astro b/src/layouts/NanBase.astro new file mode 100644 index 0000000..eb148c5 --- /dev/null +++ b/src/layouts/NanBase.astro @@ -0,0 +1,135 @@ +--- +/** + * Layout del rediseño. Lo usan todas las páginas salvo la documentación, que + * sigue en Docs.astro con utilidades de Tailwind y su propio shell. + * + * Trae el sistema visual de nan-site (tokens + reglas globales), las fuentes + * autohospedadas y la capa SEO completa. + */ +import '@fontsource-variable/archivo/wdth.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; +import '@fontsource/jetbrains-mono/700.css'; +import '../styles/nan-system.css'; + +import { getLocale, switchLocalePath, t, LOCALES } from '../lib/i18n'; + +interface Props { + title?: string; + description?: string; + /** Páginas que no deben indexarse. */ + noindex?: boolean; + /** + * Escala de titulares del sistema (H1 gigante en mayúsculas). Se apaga en + * las pantallas que todavía se escriben con utilidades de Tailwind y + * columnas estrechas, donde un H1 de 96px parte las palabras. Esas páginas + * conservan sus tamaños hasta que se rediseñen de verdad. + */ + scale?: boolean; +} + +const locale = getLocale(Astro.url); +const { + title = t('nan.meta.title', locale), + description = t('nan.meta.description', locale), + noindex = false, + scale = true, +} = Astro.props; + +// ---- URLs canónicas ------------------------------------------------------- +// Sin barra final salvo la raíz, que es como están escritos los enlaces +// internos. Canonical, og:url, alternates y sitemap apuntan todos a la misma +// variante para no competir entre sí. +const site = Astro.site ?? new URL('https://nan.builders'); +const normalize = (p: string) => p.replace(/\/+$/, '') || '/'; +const path = normalize(Astro.url.pathname); +const abs = (p: string) => new URL(normalize(p), site).href; +const absAsset = (p: string) => new URL(p, site).href; + +const canonical = abs(path); +const ogImage = absAsset(`/og/og-${locale}.png`); +const alternates = LOCALES.map((l) => ({ lang: l, href: abs(switchLocalePath(path, l)) })); +const xDefault = abs(switchLocalePath(path, 'en')); +const ogLocale = locale === 'es' ? 'es_ES' : 'en_US'; + +/** + * CSP. + * + * - `img-src` incluye `data:`: el disquete pinta el ruido del plástico con un + * SVG en data URI y sin esto el navegador lo bloquea. + * - `font-src` se queda en 'self': las fuentes van autohospedadas, no vienen + * de Google. `style-src` no lista fonts.googleapis.com por lo mismo, pero + * mantiene 'unsafe-inline' porque Astro emite estilos scoped en línea. + * - `connect-src 'self'` sigue: el formulario de waitlist llama a /api. + */ +const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "img-src 'self' data: https:", + "connect-src 'self'", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); +--- + + + + + + + {title} + + {noindex && } + + + {!noindex && ( + <> + + {alternates.map((a) => )} + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + diff --git a/src/layouts/NanLegal.astro b/src/layouts/NanLegal.astro new file mode 100644 index 0000000..03f9b87 --- /dev/null +++ b/src/layouts/NanLegal.astro @@ -0,0 +1,49 @@ +--- +import NanPage from './NanPage.astro'; +import { getLang } from '../lib/i18n'; +interface Props { title: string; updated: string; } +const { title, updated } = Astro.props; +const lang = getLang(Astro.url); +const updatedLabel = lang === 'es' ? 'Última actualización' : 'Last updated'; +const description = + lang === 'es' + ? `${title}: cómo tratamos tus datos en la comunidad NaN.` + : `${title} for the NaN community.`; +--- + + + + + + diff --git a/src/layouts/NanPage.astro b/src/layouts/NanPage.astro new file mode 100644 index 0000000..5819726 --- /dev/null +++ b/src/layouts/NanPage.astro @@ -0,0 +1,43 @@ +--- +/** + * NanBase + el shell del rediseño (nav, footer, cookie banner). + * Las páginas migradas usan este; NanBase queda para casos que necesiten + * montar el shell a mano. + */ +import NanBase from './NanBase.astro'; +import Nav from '../components/nan/Nav.astro'; +import Footer from '../components/nan/Footer.astro'; +import CookieBanner from '../components/nan/CookieBanner.astro'; + +interface Props { + title?: string; + description?: string; + noindex?: boolean; + scale?: boolean; +} +const { title, description, noindex, scale } = Astro.props; +--- + + + +
- - + + + + diff --git a/src/pages/cookies.astro b/src/pages/cookies.astro new file mode 100644 index 0000000..532f49f --- /dev/null +++ b/src/pages/cookies.astro @@ -0,0 +1,36 @@ +--- +import Legal from '../layouts/NanLegal.astro'; +--- + + +

+ NaN (operated by Helmcode S.L.) keeps cookies to the strict minimum: only the ones + needed for the site to work. We don't use analytics, advertising or cross-site tracking, ever. +

+ +

What cookies are

+

Small text files a website stores on your device to enable basic functionality.

+ +

Strictly necessary

+
    +
  • Cloudflare — security and content delivery. Sets __cf_bm and cf_clearance.
  • +
+ +

No tracking

+

+ We don't load analytics, advertising or fingerprinting scripts, and we don't build a profile of you. + There is nothing to opt out of because there is nothing tracking you in the first place. +

+ +

Other services

+

+ Google Fonts requests data to serve fonts but does not set cookies. Discord and our payment processor + operate under their own policies once you leave for those services. +

+ +

Managing cookies

+

+ You can manage or delete cookies anytime through your browser settings. Since we only set strictly + necessary cookies, blocking them may affect how the site works. +

+
diff --git a/src/pages/es/community.astro b/src/pages/es/community.astro new file mode 100644 index 0000000..5452384 --- /dev/null +++ b/src/pages/es/community.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../community.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/cookies.astro b/src/pages/es/cookies.astro new file mode 100644 index 0000000..c7949d5 --- /dev/null +++ b/src/pages/es/cookies.astro @@ -0,0 +1,36 @@ +--- +import Legal from '../../layouts/NanLegal.astro'; +--- + + +

+ NaN (operado por Helmcode S.L.) mantiene las cookies al mínimo estricto: solo las + necesarias para que el sitio funcione. Nunca usamos analítica, publicidad ni seguimiento entre sitios. +

+ +

Qué son las cookies

+

Pequeños archivos de texto que un sitio web guarda en tu dispositivo para permitir la funcionalidad básica.

+ +

Estrictamente necesarias

+
    +
  • Cloudflare: seguridad y entrega de contenido. Establece __cf_bm y cf_clearance.
  • +
+ +

Sin seguimiento

+

+ No cargamos scripts de analítica, publicidad ni fingerprinting, y no construimos un perfil sobre ti. + No hay nada que rechazar porque, para empezar, no hay nada que te siga. +

+ +

Otros servicios

+

+ Google Fonts solicita datos para servir las fuentes pero no establece cookies. Discord y nuestro + procesador de pagos operan bajo sus propias políticas una vez sales hacia esos servicios. +

+ +

Gestionar las cookies

+

+ Puedes gestionar o eliminar las cookies en cualquier momento desde la configuración de tu navegador. + Como solo establecemos cookies estrictamente necesarias, bloquearlas puede afectar al funcionamiento del sitio. +

+
diff --git a/src/pages/es/events.astro b/src/pages/es/events.astro new file mode 100644 index 0000000..774387c --- /dev/null +++ b/src/pages/es/events.astro @@ -0,0 +1,7 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../events.astro'; +--- + + diff --git a/src/pages/es/hackaton.astro b/src/pages/es/hackaton.astro new file mode 100644 index 0000000..25c0109 --- /dev/null +++ b/src/pages/es/hackaton.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../hackaton.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/hackaton/leaderboard.astro b/src/pages/es/hackaton/leaderboard.astro new file mode 100644 index 0000000..b999e91 --- /dev/null +++ b/src/pages/es/hackaton/leaderboard.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../../hackaton/leaderboard.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/hackaton/me.astro b/src/pages/es/hackaton/me.astro new file mode 100644 index 0000000..1ec41ed --- /dev/null +++ b/src/pages/es/hackaton/me.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../../hackaton/me.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/hackaton/projects.astro b/src/pages/es/hackaton/projects.astro new file mode 100644 index 0000000..64f081c --- /dev/null +++ b/src/pages/es/hackaton/projects.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../../hackaton/projects.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/hackaton/submission.astro b/src/pages/es/hackaton/submission.astro new file mode 100644 index 0000000..0bf3d81 --- /dev/null +++ b/src/pages/es/hackaton/submission.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../../hackaton/submission.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/index.astro b/src/pages/es/index.astro new file mode 100644 index 0000000..3ee9b52 --- /dev/null +++ b/src/pages/es/index.astro @@ -0,0 +1,7 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../index.astro'; +--- + + diff --git a/src/pages/es/privacy.astro b/src/pages/es/privacy.astro new file mode 100644 index 0000000..e8f7c40 --- /dev/null +++ b/src/pages/es/privacy.astro @@ -0,0 +1,52 @@ +--- +import Legal from '../../layouts/NanLegal.astro'; +--- + + +

+ NaN es la comunidad de inferencia operada por Helmcode S.L. (NIF B44788511), + Avenida Quinto Centenario 27, Santiago del Teide, España. Contacto del responsable del tratamiento: + info@helmcode.com. Esta política cubre nan.builders + y la plataforma de miembros en cloud.nan.builders. +

+ +

Qué recopilamos

+
    +
  • Lista de espera: tu email y tu región, para avisarte cuando se libere una plaza.
  • +
  • Membresía: email, handle y datos de facturación, gestionados por nuestro procesador de pagos.
  • +
+ +

La inferencia es privada

+

+ El clúster mantiene cero logs: no almacenamos tus prompts ni las respuestas del + modelo. Todo vive en tu cliente local o en tu sesión de agente. El procesamiento ocurre en + la Unión Europea, y tu código no entrena ningún modelo. Solo conservamos métricas del servidor para el + mantenimiento del clúster (tokens/s, RPM). +

+ +

Base legal

+

+ Consentimiento (lista de espera), ejecución de un contrato (tu membresía) y nuestro + interés legítimo en operar y proteger el servicio. +

+ +

Terceros

+
    +
  • Procesador de pagos, para gestionar las suscripciones bajo su propia política de privacidad.
  • +
  • Discord, donde vive la comunidad, bajo sus propios términos.
  • +
  • Cloudflare, para seguridad y distribución de contenido.
  • +
+ +

Conservación

+

Conservamos los datos personales solo durante el tiempo necesario para la finalidad para la que se recopilaron y para cumplir con nuestras obligaciones legales.

+ +

Tus derechos

+

+ Puedes acceder, rectificar, suprimir, oponerte al tratamiento o solicitar la portabilidad de tus datos + escribiendo a info@helmcode.com. También puedes presentar una + reclamación ante la Agencia Española de Protección de Datos (AEPD). +

+ +

Seguridad

+

Toda la comunicación entre tu navegador y nuestros servidores está cifrada mediante HTTPS.

+
diff --git a/src/pages/es/projects.astro b/src/pages/es/projects.astro new file mode 100644 index 0000000..6ee1105 --- /dev/null +++ b/src/pages/es/projects.astro @@ -0,0 +1,9 @@ +--- +// Misma página que la versión EN. El idioma lo resuelve getLocale(Astro.url) +// dentro de los componentes, así que no se duplica ni el marcado ni el copy. +import Page from '../projects.astro'; + +export const prerender = false; +--- + + diff --git a/src/pages/es/terms.astro b/src/pages/es/terms.astro new file mode 100644 index 0000000..f1bf1be --- /dev/null +++ b/src/pages/es/terms.astro @@ -0,0 +1,46 @@ +--- +import Legal from '../../layouts/NanLegal.astro'; +--- + + +

+ NaN está operada por Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, + Santiago del Teide, España. Contacto: info@helmcode.com. + Al acceder a nan.builders y a la plataforma de miembros aceptas estos términos. Si no estás de acuerdo, no uses el servicio. +

+ +

La comunidad

+

+ NaN es una comunidad cerrada y de pago de builders que comparten GPUs dedicadas para ejecutar modelos abiertos. El acceso está + limitado a la capacidad real del clúster: cuando se llena, abrimos una lista de espera. Cada miembro recibe una + API key personal e intransferible. +

+ +

Uso aceptable

+
    +
  • No compartas, revendas ni transfieras tu API key.
  • +
  • No abuses del clúster compartido ni intentes superar los límites publicados.
  • +
  • NaN es inferencia, no entrenamiento: usa el clúster para ejecutar modelos, no para afinarlos.
  • +
+ +

Membresía y pagos

+

+ La membresía es una suscripción mensual con impuestos incluidos, mes a mes, sin compromiso. Puedes + cancelar cuando quieras; el acceso se mantiene hasta el final del periodo pagado. +

+ +

Propiedad intelectual

+

+ El contenido del sitio es propiedad de Helmcode y no puede reproducirse sin permiso por escrito, salvo para + uso personal. Los proyectos compartidos por los miembros siguen siendo propiedad de sus autores. +

+ +

Responsabilidad

+

+ En la medida en que lo permita la ley, Helmcode no será responsable de los daños derivados del uso de este + sitio web o del servicio. Los enlaces a terceros están fuera de nuestro control. +

+ +

Ley aplicable

+

Estos términos se rigen por la ley española, y cualquier disputa se someterá a los tribunales españoles.

+
diff --git a/src/pages/events.astro b/src/pages/events.astro new file mode 100644 index 0000000..002ba36 --- /dev/null +++ b/src/pages/events.astro @@ -0,0 +1,395 @@ +--- +import NanPage from '../layouts/NanPage.astro'; +import eventos from '../data/eventos.json'; +import { getLang, useT } from '../lib/i18n'; + +const lang = getLang(Astro.url); +const tt = useT(lang).events; +const a11y = useT(lang).a11y; + +// Calendario real de los próximos 30 días. La página NO está prerenderizada, +// así que esto se recalcula en cada petición, con el reloj del Worker (UTC): +// para quien esté en Canarias o LATAM cerca de medianoche, "hoy" puede ir un +// día adelantado. Si algún día se prerenderiza, el calendario sí se congelaría +// en el build y habría que moverlo a cliente o a una isla. +const agenda = eventos.agenda ?? []; +const fmtISO = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +const dateLabel = (iso: string) => { + const [y, m, dd] = iso.split('-').map(Number); + return new Date(y, m - 1, dd).toLocaleDateString(lang === 'es' ? 'es-ES' : 'en-US', { + day: 'numeric', + month: 'short', + }); +}; +const today = new Date(); +const days = Array.from({ length: 30 }, (_, i) => { + const d = new Date(today); + d.setDate(today.getDate() + i); + const iso = fmtISO(d); + return { + day: d.getDate(), + wd: tt.weekdays[(d.getDay() + 6) % 7], + isToday: i === 0, + hasEvent: agenda.some((a) => a.date === iso), + }; +}); +const isoSet = new Set(days.map((_, i) => { + const d = new Date(today); d.setDate(today.getDate() + i); return fmtISO(d); +})); +const upcoming = agenda + .filter((a) => isoSet.has(a.date)) + .sort((a, b) => (a.date < b.date ? -1 : 1)); + +const hack = eventos.hackathons[0]; +--- + + + +
+
+
+

{tt.heroEyebrow}

+

{tt.heroTitle1}
{tt.heroTitle2}

+

{tt.heroSub}

+
+
+
+ + +
+
+
+ 01{tt.monthLabel} +
+

{tt.monthTitle}

+

{tt.monthSub}

+ +
+
+ {days.map((d) => ( +
+ {d.wd} + {d.day} + +
+ ))} +
+
+ + {upcoming.length > 0 ? ( +
    + {upcoming.map((a) => ( +
  • + {dateLabel(a.date)} + {tt.types[a.type as keyof typeof tt.types]} + {typeof a.title === 'string' ? a.title : a.title[lang]} + {a.by} +
  • + ))} +
+ ) : ( +

{tt.monthEmpty}

+ )} +
+
+ + +
+
+
+ 02{tt.workshopsLabel} +
+

{tt.workshopsTitle}

+

{tt.workshopsSub}

+ +
+ {eventos.workshops.map((v) => ( +
+
+ + + + {v.duration} + {tt.workshopsLabel} +
+

{v.date}

+

{v.title}

+
{v.tags.map((t) => {t})}
+ {tt.watchNote} +
+ ))} +
+
+
+ + +
+
+
+ 03{tt.eventsLabel} +
+

{tt.eventsTitle}

+

{tt.eventsSub}

+ +
+ {eventos.events.map((v) => ( +
+
+ + + + {v.duration} + {tt.eventsLabel} +
+

{v.date}

+

{v.title}

+
{v.tags.map((t) => {t})}
+ {tt.watchNote} +
+ ))} +
+
+
+ + +
+
+
+ 04{tt.hackathonsLabel} +
+

{tt.hackathonsTitle}

+

{tt.hackathonsSub}

+ +
+
+ + + + {hack.duration} + {tt.hackathonsLabel} +
+ {tt.hackathonsLabel} +

{hack.n} {hack.title}

+

{tt.hackathon.blurb}

+

{tt.hackathon.when}

+

{tt.hackathon.allowed}

+
+ {tt.hackathon.statusLabel} +

{tt.hackathon.statusBody}

+
+
+
+
+
+ + diff --git a/src/pages/hackaton.astro b/src/pages/hackaton.astro index 813c325..cbbca21 100644 --- a/src/pages/hackaton.astro +++ b/src/pages/hackaton.astro @@ -1,16 +1,18 @@ --- -import Page from '../layouts/Page.astro'; -import LanguageSwitcher from '../components/landing/LanguageSwitcher.astro'; +// El marcado de estas pantallas sigue escrito con utilidades de Tailwind. +// El tema de Tailwind apunta a nuestros tokens (ver styles/global.css). +import '../styles/global.css'; +import NanPage from '../layouts/NanPage.astro'; import RegisterForm, { type FormStrings } from '../components/hackaton/RegisterForm.tsx'; import LoginForm, { type LoginStrings } from '../components/hackaton/LoginForm.tsx'; import RegistrationBar from '../components/hackaton/RegistrationBar.astro'; -import { t, tObj } from '../lib/i18n'; +import { getLocale, t, tObj, withLang } from '../lib/i18n'; import { jsonData, type MeData } from '../lib/hackaton'; import { env } from 'cloudflare:workers'; export const prerender = false; -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; +const locale = getLocale(Astro.url); // Estado del evento + sesión (SSR): consultamos el backend a través del proxy // interno para decidir qué CTA mostrar. @@ -70,9 +72,9 @@ const closedNote = String(cap.closedNote ?? '') .replaceAll('{res}', String(reserve)); // Panel para participantes ya inscritos en fases posteriores a la inscripción. -const meUrl = locale === 'en' ? '/hackaton/me?lang=en' : '/hackaton/me'; -const projectsUrl = locale === 'en' ? '/hackaton/projects?lang=en' : '/hackaton/projects'; -const leaderboardUrl = locale === 'en' ? '/hackaton/leaderboard?lang=en' : '/hackaton/leaderboard'; +const meUrl = withLang('/hackaton/me', locale); +const projectsUrl = withLang('/hackaton/projects', locale); +const leaderboardUrl = withLang('/hackaton/leaderboard', locale); let participantPanel: { title: string; body: string; href: string; cta: string }; if (status === 'voting') { // Durante la votación, el CTA lleva a la galería de proyectos a votar. @@ -88,11 +90,10 @@ if (status === 'voting') { const pageTitle = `${t('hackaton.pageTitle', locale)} — nan.builders`; --- - - +
- {t('hackaton.backToLanding', locale)} @@ -166,7 +167,7 @@ const pageTitle = `${t('hackaton.pageTitle', locale)} — nan.builders`; {myParticipant.is_reserve ? already.reserve : already.confirmed}

{already.cta} @@ -191,4 +192,4 @@ const pageTitle = `${t('hackaton.pageTitle', locale)} — nan.builders`;
-
+ diff --git a/src/pages/hackaton/leaderboard.astro b/src/pages/hackaton/leaderboard.astro index b8ad00c..95a705c 100644 --- a/src/pages/hackaton/leaderboard.astro +++ b/src/pages/hackaton/leaderboard.astro @@ -1,10 +1,13 @@ --- -import Page from '../../layouts/Page.astro'; -import { t, withLang } from '../../lib/i18n'; +// El marcado de estas pantallas sigue escrito con utilidades de Tailwind. +// El tema de Tailwind apunta a nuestros tokens (ver styles/global.css). +import '../../styles/global.css'; +import NanPage from '../../layouts/NanPage.astro'; +import { getLocale, t, withLang } from '../../lib/i18n'; import { jsonData } from '../../lib/hackaton'; import { env } from 'cloudflare:workers'; export const prerender = false; -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; +const locale = getLocale(Astro.url); const base = env.CLOUD_API_URL.replace(/\/$/, ''); let lb: any = { public: false, rows: [] }; try { @@ -12,7 +15,7 @@ try { if (res.ok) lb = (await jsonData(res)) ?? lb; } catch {} --- - +
{t('hackaton.backToLanding', locale)} @@ -43,4 +46,4 @@ try { )}
-
+ diff --git a/src/pages/hackaton/me.astro b/src/pages/hackaton/me.astro index cc0b9e8..9263572 100644 --- a/src/pages/hackaton/me.astro +++ b/src/pages/hackaton/me.astro @@ -1,12 +1,15 @@ --- -import Page from '../../layouts/Page.astro'; +// El marcado de estas pantallas sigue escrito con utilidades de Tailwind. +// El tema de Tailwind apunta a nuestros tokens (ver styles/global.css). +import '../../styles/global.css'; +import NanPage from '../../layouts/NanPage.astro'; import ReassignForm from '../../components/hackaton/ReassignForm.tsx'; -import { t, tObj, withLang } from '../../lib/i18n'; +import { getLocale, t, tObj, withLang } from '../../lib/i18n'; import { jsonData, type MeData, type Team } from '../../lib/hackaton'; import { env } from 'cloudflare:workers'; export const prerender = false; -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; +const locale = getLocale(Astro.url); const base = env.CLOUD_API_URL.replace(/\/$/, ''); let team: Team | null = null, status = 'draft'; @@ -20,9 +23,10 @@ try { if (ev.ok) status = (await jsonData<{ status?: string }>(ev))?.status ?? 'draft'; } catch {} --- - +{/* Detrás de sesión: fuera del sitemap y también fuera del índice. */} +
- {t('hackaton.backToLanding', locale)} @@ -52,4 +56,4 @@ try { )}
-
+ diff --git a/src/pages/hackaton/projects.astro b/src/pages/hackaton/projects.astro index 72f3e2b..e96a8d4 100644 --- a/src/pages/hackaton/projects.astro +++ b/src/pages/hackaton/projects.astro @@ -1,13 +1,16 @@ --- -import Page from '../../layouts/Page.astro'; +// El marcado de estas pantallas sigue escrito con utilidades de Tailwind. +// El tema de Tailwind apunta a nuestros tokens (ver styles/global.css). +import '../../styles/global.css'; +import NanPage from '../../layouts/NanPage.astro'; import VoteButton from '../../components/hackaton/VoteButton.tsx'; -import { t, tObj, withLang } from '../../lib/i18n'; +import { getLocale, t, tObj, withLang } from '../../lib/i18n'; import { jsonData, type MeData } from '../../lib/hackaton'; import { env } from 'cloudflare:workers'; export const prerender = false; -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; +const locale = getLocale(Astro.url); const base = env.CLOUD_API_URL.replace(/\/$/, ''); let projects: any[] = []; // Voto actual del usuario (SSR, reenviando la cookie). Permite marcar la tarjeta @@ -29,7 +32,7 @@ const safeUrl = (u?: string): string | null => u && /^https?:\/\//i.test(u) ? u : null; --- - +
{t('hackaton.backToLanding', locale)} @@ -63,4 +66,4 @@ const safeUrl = (u?: string): string | null => )}
-
+ diff --git a/src/pages/hackaton/submission.astro b/src/pages/hackaton/submission.astro index ffc6778..767ba41 100644 --- a/src/pages/hackaton/submission.astro +++ b/src/pages/hackaton/submission.astro @@ -1,13 +1,16 @@ --- -import Page from '../../layouts/Page.astro'; +// El marcado de estas pantallas sigue escrito con utilidades de Tailwind. +// El tema de Tailwind apunta a nuestros tokens (ver styles/global.css). +import '../../styles/global.css'; +import NanPage from '../../layouts/NanPage.astro'; import SubmissionForm from '../../components/hackaton/SubmissionForm.tsx'; -import { t, tObj, withLang } from '../../lib/i18n'; +import { getLocale, t, tObj, withLang } from '../../lib/i18n'; import { jsonData, type MeData } from '../../lib/hackaton'; import { env } from 'cloudflare:workers'; export const prerender = false; -const locale = Astro.url.searchParams.get('lang') === 'es' ? 'es' : 'en'; +const locale = getLocale(Astro.url); const base = env.CLOUD_API_URL.replace(/\/$/, ''); let hasTeam = false; let existing: any = null; @@ -43,7 +46,8 @@ const readonlyRows = existing : []; --- - +{/* Detrás de sesión: fuera del sitemap y también fuera del índice. */} +
{t('hackaton.backToLanding', locale)} @@ -90,4 +94,4 @@ const readonlyRows = existing )}
-
+ diff --git a/src/pages/index.astro b/src/pages/index.astro index 2667390..638b9b6 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,7 +1,42 @@ --- -// SSR (not prerendered): the Hero counter reads the live waitlist total from KV. -import Base from '../layouts/Base.astro'; +import NanPage from '../layouts/NanPage.astro'; +import StructuredData from '../components/nan/StructuredData.astro'; +import Hero from '../components/nan/home/Hero.astro'; +import WhyNan from '../components/nan/home/WhyNan.astro'; +import BuiltWithNan from '../components/nan/home/BuiltWithNan.astro'; +import Models from '../components/nan/home/Models.astro'; +import Privacy from '../components/nan/home/Privacy.astro'; +import Pricing from '../components/nan/home/Pricing.astro'; +import WhoBehind from '../components/nan/home/WhoBehind.astro'; +import Faq from '../components/nan/home/Faq.astro'; +import { getLocale } from '../lib/i18n'; + +const locale = getLocale(Astro.url); +const site = Astro.site ?? new URL('https://nan.builders'); +const canonical = new URL(Astro.url.pathname.replace(/\/+$/, '') || '/', site).href; --- - + + + + + + + + + + + + + diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro new file mode 100644 index 0000000..3b50eea --- /dev/null +++ b/src/pages/privacy.astro @@ -0,0 +1,52 @@ +--- +import Legal from '../layouts/NanLegal.astro'; +--- + + +

+ NaN is the inference community operated by Helmcode S.L. (NIF B44788511), + Avenida Quinto Centenario 27, Santiago del Teide, Spain. Data controller contact: + info@helmcode.com. This policy covers nan.builders + and the member platform at cloud.nan.builders. +

+ +

What we collect

+
    +
  • Waitlist: your email and region, to let you know when a spot opens.
  • +
  • Membership: email, handle and billing details, handled by our payment processor.
  • +
+ +

Inference is private

+

+ The cluster keeps zero logs: we do not store your prompts or the model + responses. Everything lives in your local client or agent session. Processing happens in + the European Union, and your code trains no models. We only keep server metrics for cluster + maintenance (tokens/s, RPM). +

+ +

Legal basis

+

+ Consent (waitlist), performance of a contract (your membership) and our + legitimate interest in running and securing the service. +

+ +

Third parties

+
    +
  • Payment processor, to handle subscriptions under its own privacy policy.
  • +
  • Discord, where the community lives, under its own terms.
  • +
  • Cloudflare, for security and content delivery.
  • +
+ +

Retention

+

We keep personal data only as long as needed for the purpose collected and to meet our legal obligations.

+ +

Your rights

+

+ You can access, rectify, delete, object to processing or request portability of your data by + writing to info@helmcode.com. You may also file a + complaint with the Spanish Data Protection Agency (AEPD). +

+ +

Security

+

All communication between your browser and our servers is encrypted over HTTPS.

+ diff --git a/src/pages/projects.astro b/src/pages/projects.astro index fc0ec7e..4d948fc 100644 --- a/src/pages/projects.astro +++ b/src/pages/projects.astro @@ -1,69 +1,262 @@ --- -import Page from '../layouts/Page.astro'; -import { ProjectsGallery } from '../components/community/ProjectsGallery'; +import NanPage from '../layouts/NanPage.astro'; +import Floppy from '../components/nan/Floppy.astro'; +import { getLang, useT } from '../lib/i18n'; +import { fetchProjects } from '../lib/projects'; import { env } from 'cloudflare:workers'; export const prerender = false; -interface Project { - id: number; - handle: string; - name: string; - description: string; - imageUrl?: string; - appUrl?: string; - repoUrl?: string; - tags: string[]; - stars: number; -} - -let projects: Project[] = []; -try { - const base = env.CLOUD_API_URL.replace(/\/$/, ''); - const res = await fetch(`${base}/api/projects?limit=100`, { - headers: { origin: 'https://nan.builders' }, - }); - if (res.ok) { - const data = await res.json() as { projects: Project[] }; - projects = (data.projects ?? []).sort((a, b) => b.stars - a.stars); - } -} catch {} +const lang = getLang(Astro.url); +const tt = useT(lang).projects; +const a11y = useT(lang).a11y; + +// Datos en vivo del backend. El fetch, los filtros de esquema y el orden viven +// en lib/projects, que comparten esta página y el carrusel de la home. +const proyectos = await fetchProjects(env.CLOUD_API_URL); + +const tags = ['all', ...Array.from(new Set(proyectos.flatMap((p) => p.tags)))]; +const top = proyectos.slice(0, 6); +const rest = proyectos.slice(6); --- - -
-
- - {/* Back nav */} - - ← nan.builders - - - {/* Hero */} -
-
- Community -
-

- Community Projects -

-

- What the NaN community is building. Star your favourites on{' '} - - cloud.nan.builders - . -

+ +
+
+

{tt.eyebrow}

+

{tt.title}

+

+ {tt.subPre}{' '} + cloud.nan.builders. +

+ +
+ {tags.map((t) => ( + + ))}
- {/* Interactive gallery with search + tag filter */} - +

{tt.groupTop}

+
+ {top.map((p, i) => ( +
+ +
+
{p.tags.map((t) => {t})}
+ ★ {p.stars} +
+
+ ))} +
+ +

{tt.groupAll}

+
+ {rest.map((p, i) => ( +
+ +
+
{p.tags.map((t) => {t})}
+ ★ {p.stars} +
+
+ ))} +
+
+
+
-
+ + + + + + diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..3f67cae --- /dev/null +++ b/src/pages/sitemap.xml.ts @@ -0,0 +1,81 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import { LOCALES, withLang, type Locale } from '../lib/i18n'; + +export const prerender = false; + +/** + * Sitemap propio en vez de @astrojs/sitemap. + * + * La integración solo recoge páginas prerenderizadas y aquí el output es + * 'server': con ella el sitemap saldría vacío. Escribirlo a mano cuesta poco y + * además deja decidir explícitamente qué entra, que es lo que se quiere: + * las pantallas del hackatón que hay detrás de login no deben indexarse. + */ + +const SITE = 'https://nan.builders'; + +/** Rutas con versión en los dos idiomas. */ +const BILINGUAL = ['/', '/events', '/projects', '/community', '/privacy', '/terms', '/cookies']; + +/** + * Fuera del sitemap a propósito: + * /hackaton y sus 4 pantallas -> el hackatón #1 ya pasó (7-13 jun) y el + * registro está cerrado, así que es el + * registro de un evento, no contenido vivo. + * Ninguna página lo enlaza, y declararlo + * indexable sin entrada era pedir que se + * indexara algo a lo que solo se llega + * escribiendo la URL. Van todas con noindex. + * Cuando haya un #2 se rediseñan las cinco + * pantallas y se le da entrada, en su PR. + * /404 -> noindex + * /api/* -> no son páginas + */ + +// La raíz conserva la barra para que coincida exactamente con el canonical +// que emite NanBase; el resto va sin barra final. +const abs = (path: string) => (path === '/' ? `${SITE}/` : `${SITE}${path}`); + +function urlEntry(path: string, alternates: { lang: Locale; href: string }[]): string { + const links = alternates + .map((a) => ``) + .join(''); + const xDefault = alternates.find((a) => a.lang === 'en'); + return ( + `${abs(path)}${links}` + + (xDefault ? `` : '') + + `` + ); +} + +export const GET: APIRoute = async () => { + const entries: string[] = []; + + for (const path of BILINGUAL) { + const alternates = LOCALES.map((lang) => ({ lang, href: abs(withLang(path, lang)) })); + for (const { href } of alternates) { + entries.push(urlEntry(href.replace(SITE, '') || '/', alternates)); + } + } + + // La documentación solo existe en inglés (la colección MDX no está traducida), + // así que va sin alternates. + const docs = await getCollection('docs'); + for (const doc of docs) { + entries.push(`${abs(`/docs/${doc.id}`)}`); + } + + const xml = + `` + + `` + + entries.join('') + + ``; + + return new Response(xml, { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'cache-control': 'public, max-age=3600', + }, + }); +}; diff --git a/src/pages/terms.astro b/src/pages/terms.astro new file mode 100644 index 0000000..6fb7148 --- /dev/null +++ b/src/pages/terms.astro @@ -0,0 +1,46 @@ +--- +import Legal from '../layouts/NanLegal.astro'; +--- + + +

+ NaN is operated by Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, + Santiago del Teide, Spain. Contact: info@helmcode.com. + By accessing nan.builders and the member platform you accept these terms. If you disagree, do not use the service. +

+ +

The community

+

+ NaN is a closed, paid community of builders sharing dedicated GPUs to run open models. Access is + limited to the real capacity of the cluster: when it fills up, we open a waitlist. Every member gets a + personal, non-transferable API key. +

+ +

Acceptable use

+
    +
  • Do not share, resell or transfer your API key.
  • +
  • Do not abuse the shared cluster or attempt to exceed the published limits.
  • +
  • NaN is inference, not training: use the cluster to run models, not to fine-tune them.
  • +
+ +

Membership and payments

+

+ Membership is a monthly subscription with taxes included, month to month, with no commitment. You can + cancel anytime; access runs until the end of the paid period. +

+ +

Intellectual property

+

+ Site content is owned by Helmcode and may not be reproduced without written permission, except for + personal use. Projects shared by members remain the property of their authors. +

+ +

Liability

+

+ To the extent permitted by law, Helmcode will not be liable for damages arising from the use of this + website or the service. Third-party links are outside our control. +

+ +

Governing law

+

These terms are governed by Spanish law, and any dispute will be submitted to the Spanish courts.

+
diff --git a/src/scripts/heroDither.ts b/src/scripts/heroDither.ts new file mode 100644 index 0000000..72c306a --- /dev/null +++ b/src/scripts/heroDither.ts @@ -0,0 +1,175 @@ +/* Ilustración dithered del hero: blanca por defecto, crossfade a violeta al + hover sobre sus píxeles reales. Estela de partículas que sube del cráneo. + Reconstruida en canvas desde /img/hero-dither.webp (negro -> transparente). */ +type Plume = { x: number; y: number; vx: number; vy: number }; + +export function initHeroDither(canvasId: string, src = '/img/hero-dither.webp') { + const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const VIOLET: [number, number, number] = [125, 57, 235]; + const THRESHOLD = 92, PLUME_N = 80, PX = 2.5; + + let W = 0, H = 0, imgAspect = 1; + let bufW: HTMLCanvasElement | null = null, bufV: HTMLCanvasElement | null = null; + let maskImg: ImageData | null = null; + let cx = 0, cy = 0, sw = 0, sh = 0; + let plume: Plume[] = []; + let hover = 0, hoverTarget = 0; + + const mixInk = (h: number) => + `rgb(${Math.round(255 + (125 - 255) * h)},${Math.round(255 + (57 - 255) * h)},${Math.round(255 + (235 - 255) * h)})`; + + function buildBuffers() { + if (!maskImg) return; + const w = maskImg.width, h = maskImg.height, s = maskImg.data; + bufW = document.createElement('canvas'); bufW.width = w; bufW.height = h; + bufV = document.createElement('canvas'); bufV.width = w; bufV.height = h; + const cw = bufW.getContext('2d')!, cv = bufV.getContext('2d')!; + const ow = cw.createImageData(w, h), ov = cv.createImageData(w, h); + const dw = ow.data, dv = ov.data, [r, g, b] = VIOLET; + for (let i = 0; i < s.length; i += 4) { + const on = s[i] > THRESHOLD ? 255 : 0; + dw[i] = 255; dw[i + 1] = 255; dw[i + 2] = 255; dw[i + 3] = on; + dv[i] = r; dv[i + 1] = g; dv[i + 2] = b; dv[i + 3] = on; + } + cw.putImageData(ow, 0, 0); cv.putImageData(ov, 0, 0); + } + + function makePlume(fresh: boolean): Plume { + const topY = cy - sh / 2; + return { + x: cx + (Math.random() - 0.5) * sw * 0.55, + y: topY - (fresh ? Math.random() * sh * 0.85 : Math.random() * sh * 0.15), + vx: (Math.random() - 0.5) * 0.12, + vy: -(0.08 + Math.random() * 0.28), + }; + } + function seedPlume() { plume = []; for (let i = 0; i < PLUME_N; i++) plume.push(makePlume(true)); } + + function resize() { + W = canvas!.clientWidth; H = canvas!.clientHeight; + canvas!.width = W * dpr; canvas!.height = H * dpr; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + sw = Math.min(W * 0.6, H * 1.0 * imgAspect); + sh = sw / imgAspect; + cx = W * 0.74; cy = H * 0.52; + seedPlume(); + } + + function overSkull(px: number, py: number) { + if (!maskImg) return false; + const dx = cx - sw / 2, dy = cy - sh / 2; + if (px < dx || px > dx + sw || py < dy || py > dy + sh) return false; + const w = maskImg.width, h = maskImg.height, s = maskImg.data; + const bx = Math.floor((px - dx) / sw * w), by = Math.floor((py - dy) / sh * h); + for (let oy = -3; oy <= 3; oy++) for (let ox = -3; ox <= 3; ox++) { + const mx = bx + ox, my = by + oy; + if (mx < 0 || my < 0 || mx >= w || my >= h) continue; + if (s[(my * w + mx) * 4] > THRESHOLD) return true; + } + return false; + } + + function drawSkull() { + if (!bufW) return; + const dx = cx - sw / 2, dy = cy - sh / 2; + ctx!.imageSmoothingEnabled = false; + ctx!.globalAlpha = 1; ctx!.drawImage(bufW, dx, dy, sw, sh); + if (hover > 0.002) { ctx!.globalAlpha = hover; ctx!.drawImage(bufV!, dx, dy, sw, sh); } + ctx!.globalAlpha = 1; + } + function drawPlume() { + const topY = cy - sh / 2; + ctx!.fillStyle = mixInk(hover); + for (let i = 0; i < plume.length; i++) { + const p = plume[i]; + p.x += p.vx; p.y += p.vy; + const rise = topY - p.y; + if (rise > sh * 0.95) { plume[i] = makePlume(false); continue; } + ctx!.globalAlpha = Math.max(0, 0.75 * (1 - rise / (sh * 0.95))); + ctx!.fillRect(p.x, p.y, PX, PX); + } + ctx!.globalAlpha = 1; + } + let rafId = 0; + let visible = true; + + function frame() { + hover += (hoverTarget - hover) * 0.09; + ctx!.clearRect(0, 0, W, H); + drawSkull(); + drawPlume(); + rafId = requestAnimationFrame(frame); + } + + /** + * A diferencia del dither de WhyNan, aquí el bucle NO puede pararse cuando el + * crossfade se estabiliza: la estela de partículas se mueve siempre. Lo que sí + * se puede es no animar cuando el hero no se ve, que es la mayor parte del + * tiempo que alguien pasa en la home. + */ + function start() { + if (rafId || reduce || !visible) return; + rafId = requestAnimationFrame(frame); + } + function stop() { + if (!rafId) return; + cancelAnimationFrame(rafId); + rafId = 0; + } + + const img = new Image(); + img.onload = () => { + imgAspect = img.width / img.height; + const tmp = document.createElement('canvas'); + tmp.width = img.width; tmp.height = img.height; + const bctx = tmp.getContext('2d')!; + bctx.drawImage(img, 0, 0); + const im = bctx.getImageData(0, 0, img.width, img.height); + const d = im.data; + for (let i = 0; i < d.length; i += 4) d[i] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; + maskImg = im; + buildBuffers(); + resize(); + if (reduce) { drawSkull(); drawPlume(); } else start(); + }; + img.src = src; + + /** + * El rect va cacheado. Antes se pedía un `getBoundingClientRect()` en CADA + * movimiento de ratón sobre la página entera, y eso fuerza layout: con dos + * canvas dither eran dos reflows por cada movimiento del cursor. + * + * El listener sigue en `window` y no en el canvas porque `#hero-fx` lleva + * `pointer-events: none` (está detrás del contenido del hero), así que sobre + * él no llegaría ningún evento. + */ + let rect: DOMRect | null = null; + const invalidateRect = () => { rect = null; }; + + window.addEventListener('resize', () => { resize(); invalidateRect(); }); + window.addEventListener('scroll', invalidateRect, { passive: true }); + + window.addEventListener('pointermove', (e) => { + if (!visible) return; // hero fuera de pantalla: no hay nada que resaltar + if (!rect) rect = canvas!.getBoundingClientRect(); + const next = overSkull(e.clientX - rect.left, e.clientY - rect.top) ? 1 : 0; + if (next !== hoverTarget) hoverTarget = next; + }); + + if ('IntersectionObserver' in window) { + new IntersectionObserver((entries) => { + visible = entries[0]?.isIntersecting ?? true; + if (visible) start(); else stop(); + }).observe(canvas); + } + + document.addEventListener('visibilitychange', () => { + if (document.hidden) stop(); else start(); + }); +} diff --git a/src/scripts/initDither.ts b/src/scripts/initDither.ts new file mode 100644 index 0000000..77eb651 --- /dev/null +++ b/src/scripts/initDither.ts @@ -0,0 +1,157 @@ +/* Dither "enmarcado": encaja una imagen dentro de su canvas (contain), blanca por + defecto y crossfade a violeta al hover sobre sus píxeles. Mismo lenguaje que el + hero, sin la estela. Fuente: imagen clara sobre fondo oscuro (se keyea el negro). */ +type Opts = { threshold?: number; pad?: number }; + +export function initDither(canvasId: string, src: string, opts: Opts = {}) { + const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const THRESHOLD = opts.threshold ?? 92; + const PAD = opts.pad ?? 0.92; + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const VIOLET: [number, number, number] = [125, 57, 235]; + + let W = 0, H = 0, iw = 1, ih = 1; + let bufW: HTMLCanvasElement | null = null, bufV: HTMLCanvasElement | null = null; + let maskImg: ImageData | null = null; + let dx = 0, dy = 0, dw = 0, dh = 0; + let hover = 0, hoverTarget = 0; + + function buildBuffers() { + if (!maskImg) return; + const w = maskImg.width, h = maskImg.height, s = maskImg.data; + bufW = document.createElement('canvas'); bufW.width = w; bufW.height = h; + bufV = document.createElement('canvas'); bufV.width = w; bufV.height = h; + const cw = bufW.getContext('2d')!, cv = bufV.getContext('2d')!; + const ow = cw.createImageData(w, h), ov = cv.createImageData(w, h); + const dwr = ow.data, dvr = ov.data, [r, g, b] = VIOLET; + for (let i = 0; i < s.length; i += 4) { + const on = s[i] > THRESHOLD ? 255 : 0; + dwr[i] = 255; dwr[i + 1] = 255; dwr[i + 2] = 255; dwr[i + 3] = on; + dvr[i] = r; dvr[i + 1] = g; dvr[i + 2] = b; dvr[i + 3] = on; + } + cw.putImageData(ow, 0, 0); cv.putImageData(ov, 0, 0); + } + + function resize() { + W = canvas!.clientWidth; H = canvas!.clientHeight; + canvas!.width = W * dpr; canvas!.height = H * dpr; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + const scale = Math.min((W * PAD) / iw, (H * PAD) / ih); + dw = iw * scale; dh = ih * scale; + dx = (W - dw) / 2; dy = (H - dh) / 2; + } + + function overImg(px: number, py: number) { + if (!maskImg || px < dx || px > dx + dw || py < dy || py > dy + dh) return false; + const w = maskImg.width, h = maskImg.height, s = maskImg.data; + const bx = Math.floor((px - dx) / dw * w), by = Math.floor((py - dy) / dh * h); + for (let oy = -2; oy <= 2; oy++) for (let ox = -2; ox <= 2; ox++) { + const mx = bx + ox, my = by + oy; + if (mx < 0 || my < 0 || mx >= w || my >= h) continue; + if (s[(my * w + mx) * 4] > THRESHOLD) return true; + } + return false; + } + + function draw() { + if (!bufW) return; + ctx!.clearRect(0, 0, W, H); + ctx!.imageSmoothingEnabled = false; + ctx!.globalAlpha = 1; ctx!.drawImage(bufW, dx, dy, dw, dh); + if (hover > 0.002) { ctx!.globalAlpha = hover; ctx!.drawImage(bufV!, dx, dy, dw, dh); } + ctx!.globalAlpha = 1; + } + /** + * Por debajo de esto el crossfade ya no se distingue: se cuadra al objetivo, + * se pinta un último fotograma y el bucle se para. + * + * Antes no paraba nunca: redibujaba en cada fotograma aunque `hover` ya + * hubiera llegado a `hoverTarget` y aunque el canvas estuviera fuera de + * pantalla. Entre este dither, el del hero y las partículas, la home tenía + * tres bucles rAF permanentes. + */ + const SETTLED = 0.002; + + let rafId = 0; + let visible = true; + + function frame() { + hover += (hoverTarget - hover) * 0.09; + if (Math.abs(hoverTarget - hover) < SETTLED) { + hover = hoverTarget; + draw(); + rafId = 0; // se ha estabilizado: nada que animar + return; + } + draw(); + rafId = requestAnimationFrame(frame); + } + + /** Despierta el bucle. Lo llaman los eventos de puntero, no un rAF eterno. */ + function wake() { + if (reduce || rafId || !visible) return; + rafId = requestAnimationFrame(frame); + } + + const img = new Image(); + img.onload = () => { + iw = img.width; ih = img.height; + const tmp = document.createElement('canvas'); + tmp.width = iw; tmp.height = ih; + const bctx = tmp.getContext('2d')!; + bctx.drawImage(img, 0, 0); + const im = bctx.getImageData(0, 0, iw, ih); + const d = im.data; + for (let i = 0; i < d.length; i += 4) d[i] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; + maskImg = im; + buildBuffers(); + resize(); + draw(); + }; + img.src = src; + + window.addEventListener('resize', () => { resize(); draw(); }); + + /** + * `pointermove` sobre el canvas y no `mousemove` sobre `window`: antes se + * ejecutaba en cada movimiento de ratón en toda la página, y cada vez hacía un + * `getBoundingClientRect()` (que fuerza layout) más un escaneo de 25 píxeles + * de la máscara. Aquí solo corre encima del canvas, y el rect va cacheado. + */ + let rect: DOMRect | null = null; + const invalidateRect = () => { rect = null; }; + window.addEventListener('resize', invalidateRect); + window.addEventListener('scroll', invalidateRect, { passive: true }); + + canvas.addEventListener('pointermove', (e) => { + if (!rect) rect = canvas.getBoundingClientRect(); + const next = overImg(e.clientX - rect.left, e.clientY - rect.top) ? 1 : 0; + if (next === hoverTarget) return; // nada ha cambiado: no despertar el bucle + hoverTarget = next; + wake(); + }); + + canvas.addEventListener('pointerleave', () => { + if (hoverTarget === 0) return; + hoverTarget = 0; + wake(); + }); + + // Fuera de pantalla no se anima. Al volver a entrar, si quedaba crossfade a + // medias, se retoma. + if ('IntersectionObserver' in window) { + new IntersectionObserver((entries) => { + visible = entries[0]?.isIntersecting ?? true; + if (!visible) { + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + } else if (hover !== hoverTarget) { + wake(); + } + }).observe(canvas); + } +} diff --git a/src/scripts/nanGlitch.ts b/src/scripts/nanGlitch.ts new file mode 100644 index 0000000..d528891 --- /dev/null +++ b/src/scripts/nanGlitch.ts @@ -0,0 +1,39 @@ +/* Firma de marca: al hover, un número glitchea y se resuelve en NaN / ∞ + (NaN = Not a Number → aquí no se cuenta nada). Vuelve al valor al salir. */ +export function initNanGlitch() { + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const GLYPHS = '0123456789.$€/∞'; + + document.querySelectorAll('[data-nan]').forEach((el) => { + const orig = (el.textContent ?? '').trim(); + const to = el.dataset.nan || 'NaN'; + el.style.display ||= 'inline-block'; + let raf = 0; + let running = false; // evita reinicios mientras ya está resuelto en NaN + + const scramble = () => { + if (reduce) { el.textContent = to; return; } + if (running) return; + running = true; + const start = performance.now(); + const dur = 140; // corto: no debe parecer un error + const len = Math.max(orig.length, to.length); + const tick = (now: number) => { + const p = Math.min(1, (now - start) / dur); + if (p < 0.5) { + let out = ''; + for (let i = 0; i < len; i++) out += GLYPHS[(Math.random() * GLYPHS.length) | 0]; + el.textContent = out; + raf = requestAnimationFrame(tick); + } else { + el.textContent = to; // NaN aparece pronto (~70ms) y se queda fijo + } + }; + raf = requestAnimationFrame(tick); + }; + const restore = () => { running = false; cancelAnimationFrame(raf); el.textContent = orig; }; + + el.addEventListener('pointerenter', scramble); + el.addEventListener('pointerleave', restore); + }); +} diff --git a/src/scripts/particles.ts b/src/scripts/particles.ts new file mode 100644 index 0000000..79a4750 --- /dev/null +++ b/src/scripts/particles.ts @@ -0,0 +1,95 @@ +/* Campo de partículas violeta global — textura de la casa. Sutil, en #bg-fx. */ +type P = { x: number; y: number; vx: number; vy: number; a: number }; + +export function initParticles() { + const canvas = document.getElementById('bg-fx') as HTMLCanvasElement | null; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const VIOLET = '#7D39EB'; + const COUNT = 300; + const SIZE = 3.4; + let W = 0, H = 0; + let parts: P[] = []; + + function seed() { + parts = []; + for (let i = 0; i < COUNT; i++) { + parts.push({ + x: Math.random() * W, y: Math.random() * H, + vx: (Math.random() - 0.5) * 0.28, vy: (Math.random() - 0.5) * 0.28, + a: 0.3 + Math.random() * 0.45, + }); + } + } + function resize() { + W = window.innerWidth; H = window.innerHeight; + canvas!.width = W * dpr; canvas!.height = H * dpr; + canvas!.style.width = W + 'px'; canvas!.style.height = H + 'px'; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!parts.length) seed(); + } + function draw() { + ctx!.clearRect(0, 0, W, H); + ctx!.fillStyle = VIOLET; + for (const p of parts) { + p.x += p.vx; p.y += p.vy; + if (p.x < -4) p.x = W + 4; else if (p.x > W + 4) p.x = -4; + if (p.y < -4) p.y = H + 4; else if (p.y > H + 4) p.y = -4; + ctx!.globalAlpha = p.a; + ctx!.fillRect(p.x, p.y, SIZE, SIZE); + } + ctx!.globalAlpha = 1; + } + /** + * Opacidad mínima al salir del hero. Antes el bucle seguía pintando las 300 + * partículas a este 10%, o sea batería a cambio de un movimiento que nadie + * puede percibir detrás del contenido: por debajo de este umbral se para. + */ + const MIN_OPACITY = 0.1; + + let rafId = 0; + const running = () => rafId !== 0; + + function frame() { draw(); rafId = requestAnimationFrame(frame); } + + function start() { + if (running() || reduce) return; + rafId = requestAnimationFrame(frame); + } + function stop() { + if (!running()) return; + cancelAnimationFrame(rafId); + rafId = 0; + } + + // se desvanecen al salir del hero para no molestar la lectura + function fade() { + const f = Math.max(MIN_OPACITY, 1 - window.scrollY / (window.innerHeight * 0.85)); + canvas!.style.opacity = String(f); + // Parar y arrancar según se sale y se vuelve al hero: el usuario puede + // subir otra vez y entonces sí se ven. + if (f <= MIN_OPACITY) stop(); else start(); + } + + window.addEventListener('scroll', fade, { passive: true }); + + // `fade()` además de `resize()`: el umbral depende de `innerHeight`, así que al + // rotar el móvil el hero puede volver a estar en pantalla (o dejar de estarlo) + // sin que medie un scroll. Sin esto, la decisión de animar se quedaba con la + // altura anterior hasta el siguiente scroll. + window.addEventListener('resize', () => { resize(); fade(); }); + + // Fuera de la pestaña activa el navegador ya frena rAF, pero al volver + // conviene no reanudar si seguimos con el hero fuera de pantalla. + document.addEventListener('visibilitychange', () => { + if (document.hidden) stop(); else fade(); + }); + + resize(); + if (reduce) { draw(); fade(); return; } + fade(); +} diff --git a/src/scripts/reveal.ts b/src/scripts/reveal.ts new file mode 100644 index 0000000..842f55b --- /dev/null +++ b/src/scripts/reveal.ts @@ -0,0 +1,35 @@ +/* Reveal al entrar en viewport. [data-reveal] = un bloque; [data-reveal-stagger] + = contenedor cuyos hijos aparecen en cascada. Respeta prefers-reduced-motion. */ +export function initReveal() { + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const single = Array.from(document.querySelectorAll('[data-reveal]')); + const groups = Array.from(document.querySelectorAll('[data-reveal-stagger]')); + + if (reduce) { + single.forEach((el) => el.classList.add('is-in')); + groups.forEach((g) => Array.from(g.children).forEach((c) => c.classList.add('is-in'))); + return; + } + + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + const el = e.target as HTMLElement; + if (el.hasAttribute('data-reveal-stagger')) { + Array.from(el.children).forEach((c, i) => { + (c as HTMLElement).style.transitionDelay = `${Math.min(i * 60, 480)}ms`; + c.classList.add('is-in'); + }); + } else { + el.classList.add('is-in'); + } + io.unobserve(el); + } + }, + { rootMargin: '0px 0px -8% 0px', threshold: 0.08 } + ); + + single.forEach((el) => io.observe(el)); + groups.forEach((g) => io.observe(g)); +} diff --git a/src/scripts/scramble.ts b/src/scripts/scramble.ts new file mode 100644 index 0000000..db1389c --- /dev/null +++ b/src/scripts/scramble.ts @@ -0,0 +1,52 @@ +/** + * NaN — scramble de letras al hover. + * Es el único movimiento tipográfico del sistema: el texto se descompone en + * glifos y se resuelve de izquierda a derecha. Vive SOLO en la banda del slug + * de los disquettes — en botones y enlaces resultaba excesivo. + * + * Se aplica solo a elementos con [data-scramble-hover]; scramble.ts los cablea + * en carga y respeta prefers-reduced-motion. + */ + +const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/\\<>_#*'; +const prefersReduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** Anchura fija mientras muta: nada de saltos de layout. */ +function run(el: HTMLElement, dur = 420): void { + if (prefersReduced()) return; + const target = el.dataset.text ?? el.textContent ?? ''; + if (!target.trim()) return; + + // si ya está corriendo, no encadenamos dos animaciones sobre el mismo nodo + if (el.dataset.scrambling === '1') return; + el.dataset.scrambling = '1'; + + const start = performance.now(); + const tick = (now: number) => { + const p = Math.min(1, (now - start) / dur); + const solved = Math.floor(target.length * p); + let out = target.slice(0, solved); + for (let i = solved; i < target.length; i++) { + const ch = target[i]; + out += ch === ' ' || ch === '·' || ch === '→' ? ch : GLYPHS[(Math.random() * GLYPHS.length) | 0]; + } + el.textContent = out; + if (p < 1) { + requestAnimationFrame(tick); + } else { + el.textContent = target; + delete el.dataset.scrambling; + } + }; + requestAnimationFrame(tick); +} + +export function initScramble(): void { + document.querySelectorAll('[data-scramble-hover]').forEach((el) => { + el.dataset.text = (el.textContent ?? '').trim(); + // ancho reservado: el nodo no cambia de tamaño mientras muta + el.style.display ||= 'inline-block'; + el.addEventListener('pointerenter', () => run(el)); + el.addEventListener('focus', () => run(el)); + }); +} diff --git a/src/scripts/waitlist.ts b/src/scripts/waitlist.ts new file mode 100644 index 0000000..4c0ee66 --- /dev/null +++ b/src/scripts/waitlist.ts @@ -0,0 +1,129 @@ +/** + * NaN — envío de la waitlist. Cablea todos los
. + * POST /api/waitlist {email, region, _hp} → ver src/pages/api/waitlist.ts + * + * La VALIDACIÓN y el PARSEO no viven aquí: se reutilizan los helpers que ya + * usaba la isla Preact (lib/waitlistClient), para que haya + * una sola implementación y la sigan cubriendo sus tests. En particular el + * espejo de dominios bloqueados, que debe coincidir con el de lib/waitlist.ts + * (el servidor es la autoridad; el cliente solo evita un viaje de ida y vuelta). + * + * Los TEXTOS sí son de aquí: el componente los serializa en data-msgs desde el + * diccionario, para no arrastrar todo el i18n al bundle de cliente. + */ +import { + isValidEmail, + isWaitlistRegion, + normalizeEmail, + parseWaitlistResponse, + waitlistErrorText, + waitlistSuccessText, +} from '../lib/waitlistClient'; + +interface Msgs { + sending: string; + okRegistered: string; + okInterest: string; + okPosition: string; + okText: string; + errEmail: string; + errRegion: string; + errRateLimited: string; + errNetwork: string; + errGeneric: string; +} + +const FALLBACK: Msgs = { + sending: 'Sending…', + okRegistered: "You're on the waitlist.", + okInterest: 'Noted. We are not open in your region yet.', + okPosition: 'position', + okText: "We'll approve your spot in the next few days.", + errEmail: 'That email does not look valid.', + errRegion: 'Pick a region.', + errRateLimited: 'Too many attempts. Wait a minute.', + errNetwork: 'The network failed, not you. Try again.', + errGeneric: 'Something broke on our side. Try again in a bit.', +}; + +function readMsgs(form: HTMLFormElement): Msgs { + try { + return { ...FALLBACK, ...JSON.parse(form.dataset.msgs ?? '{}') }; + } catch { + return FALLBACK; + } +} + +function wire(form: HTMLFormElement): void { + const status = form.querySelector('[data-status]'); + const submit = form.querySelector('[data-submit]'); + if (!status || !submit) return; + + const t = readMsgs(form); + const email = form.querySelector('input[name="email"]'); + + /** Error: lo anuncia el live region, marca el campo y le devuelve el foco. */ + const fail = (msg: string, focus?: HTMLElement | null) => { + status.textContent = msg; + submit.disabled = false; + if (focus) { + focus.setAttribute('aria-invalid', 'true'); + focus.focus(); + } + }; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const value = normalizeEmail(String(data.get('email') ?? '')); + const region = String(data.get('region') ?? ''); + const hp = String(data.get('_hp') ?? ''); + + email?.removeAttribute('aria-invalid'); + + // Mismo criterio que la isla Preact: formato, longitud y dominios de + // ejemplo o desechables. + if (!isValidEmail(value)) { + fail(t.errEmail, email); + return; + } + if (form.querySelector('[name="region"]') && !isWaitlistRegion(region)) { + fail(t.errRegion, form.querySelector('[data-region] button')); + return; + } + + submit.disabled = true; + status.textContent = t.sending; + + let res: Response; + try { + res = await fetch('/api/waitlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: value, region, _hp: hp }), + }); + } catch { + fail(t.errNetwork); + return; + } + + const body = await res.json().catch(() => null); + const result = parseWaitlistResponse(res.status, body); + + if (!result.ok) { + // Qué texto toca y si el foco vuelve al campo se decide en + // lib/waitlistClient, que sí tiene tests. Aquí queda el cableado del DOM. + const { text, focusEmail } = waitlistErrorText(result.error, t); + fail(text, focusEmail ? email : null); + return; + } + + form.querySelectorAll('input, select, button').forEach((el) => ((el as HTMLInputElement).disabled = true)); + + status.textContent = waitlistSuccessText(result, t); + }); +} + +export function initWaitlist(): void { + document.querySelectorAll('form[data-waitlist]').forEach(wire); +} diff --git a/src/styles/global.css b/src/styles/global.css index 909ba5f..e47537d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,8 +1,33 @@ @import "tailwindcss"; +/* + * Tema de Tailwind apuntado a los tokens del rediseño. + * + * Las pantallas que siguen escritas con utilidades (el hackatón, el formulario + * de alta de community) heredan así la tipografía, el violeta y la esquina + * recta sin tener que reescribir clase por clase. `bg-violet-600`, + * `rounded-lg` o `font-sans` pasan a significar lo que dice tokens.css. + * + * Los valores viven en src/styles/tokens.css, que es la fuente de verdad. + */ @theme { - --font-sans: "Inter", system-ui, sans-serif; + --font-sans: "Archivo Variable", "Archivo", system-ui, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* monoacento: toda la escala violeta de Tailwind colapsa a los dos tonos + del sistema, para que no se cuele un segundo acento por la puerta de atrás */ + --color-violet-300: #9B6BF0; + --color-violet-400: #9B6BF0; + --color-violet-500: #7D39EB; + --color-violet-600: #7D39EB; + --color-violet-700: #7D39EB; + + /* esquina recta en toda la UI */ + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; + --radius-2xl: 0; } @keyframes fade-in-up { @@ -80,7 +105,10 @@ } .docs-content p { - font-family: "Inter", system-ui, sans-serif; + /* Antes era Inter servida por Google Fonts. La tipografía del sistema es + Archivo y va autohospedada, así que el cuerpo de la documentación usa la + misma familia que el resto del sitio. */ + font-family: var(--font-sans); font-size: 0.875rem; line-height: 1.75; color: rgb(203 213 225); diff --git a/src/styles/nan-system.css b/src/styles/nan-system.css new file mode 100644 index 0000000..70abd79 --- /dev/null +++ b/src/styles/nan-system.css @@ -0,0 +1,292 @@ +/* + * NaN — sistema visual del rediseño. + * Archivo (display y cuerpo) · JetBrains Mono (técnico). + * + * OJO: esto NO se carga globalmente. Lo importa src/layouts/NanBase.astro, así + * que solo afecta a las páginas ya migradas. Las que siguen en Tailwind (docs, + * hackaton) usan styles/global.css y no lo ven. Cuando estén todas migradas se + * puede unificar y borrar esta separación. + * + * Off-black, monoacento violeta, esquina recta, solo dark. + * Textura de la casa = campo de partículas (canvas #bg-fx en Base), no grano. + * Regla de motion: interactivo -> violeta con transición. + */ +@import './tokens.css'; + +/* BREAKPOINTS (convención). El CSS es scoped por componente, así que no hay un + token compartido posible: usa estos tres y no inventes valores nuevos. + 620px -> móvil: una columna, todo apilado + 860px -> tablet: cae la nav a hamburguesa y los grids a 1-2 columnas + 1100px -> desktop estrecho: ajustes finos + Quedan sueltos algunos 520/720/760/900 de la primera iteración; migrarlos + pide un repaso visual ancho a ancho, no un buscar-y-reemplazar. */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + border-radius: var(--radius-ui); +} + +html { + color-scheme: dark; + overflow-x: clip; + scroll-behavior: smooth; + /* deja aire bajo la nav sticky al saltar a un ancla */ + scroll-padding-top: calc(var(--nav-h) + 20px); +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} + +body { + background: + radial-gradient(1100px 800px at 72% 28%, rgba(125, 57, 235, 0.22), rgba(125, 57, 235, 0) 62%), + radial-gradient(900px 720px at 10% 98%, rgba(125, 57, 235, 0.12), rgba(125, 57, 235, 0) 60%), + var(--color-bg); + background-attachment: fixed; + color: var(--color-text); + /* el fondo fijo se resuelve abajo: en móvil sale caro repintarlo */ + font-family: var(--font-sans); + font-weight: 400; + font-size: 17px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* campo de partículas violeta (pintado por scripts/particles.ts) */ +#bg-fx { + position: fixed; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + pointer-events: none; +} +main, footer { position: relative; z-index: 1; } + +/* Titulares: Archivo variable EXPANDIDO (wdth+wght) y en mayúsculas. + Sistema UNIFORME en toda la web (mismos tamaños). El !important gana a los + estilos scoped de cada componente (clase + atributo de Astro > h1 pelado). */ +/* ESCALA DE TITULARES — solo bajo .nan-scale (lo pone NanBase salvo que la + página pida lo contrario). Va con !important para ganar a los estilos scoped + de cada componente, y por eso mismo aplastaría las pantallas que aún se + escriben con utilidades de Tailwind y columnas estrechas: allí un H1 de 96px + parte las palabras. Esas páginas montan el shell con scale={false}. */ +.nan-scale h1, +.nan-scale h2, +.nan-scale h3, +.nan-scale h4 { + font-family: var(--font-serif); + text-transform: uppercase !important; + /* red de seguridad: si algún día entra una palabra más larga que la caja, + que parta en vez de salirse y provocar scroll horizontal */ + overflow-wrap: break-word; +} +.nan-scale h1 { + font-variation-settings: 'wdth' 118, 'wght' 850 !important; + font-size: var(--fs-h1) !important; + line-height: 0.98 !important; + letter-spacing: -0.02em !important; +} +.nan-scale h2 { + font-variation-settings: 'wdth' 115, 'wght' 800 !important; + font-size: var(--fs-h2) !important; + line-height: 1.05 !important; + letter-spacing: -0.01em !important; +} +.nan-scale h3 { + font-variation-settings: 'wdth' 110, 'wght' 750 !important; + font-size: var(--fs-h3) !important; + line-height: 1.2 !important; +} + +/* segunda línea de un titular en violeta (como "Start burning tokens") */ +.hl { color: var(--color-violet-2); } + +strong, b { font-weight: 700; } +/* párrafo de la casa: Archivo, anchura por defecto, blanco */ +p { color: var(--color-text); font-family: var(--font-serif); font-variation-settings: normal; } + +a { color: inherit; text-decoration: none; transition: color var(--dur-micro) var(--ease); } + +code, pre, kbd { font-family: var(--font-mono); font-size: 0.9em; } +img, svg { max-width: 100%; display: block; } +button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; } + +::selection { background: var(--color-violet); color: #fff; } +/* Anillo de foco en violeta CLARO: el violeta base sobre off-black se queda en + 3.4:1 y encima desaparece sobre los paneles con tinte violeta. */ +:focus-visible { + outline: 2px solid var(--color-violet-2); + outline-offset: 3px; +} + +/* Saltar al contenido: fuera de pantalla hasta que recibe foco. */ +.skip-link { + position: absolute; + top: 0; + left: 0; + z-index: 500; + transform: translateY(-120%); + padding: 12px 20px; + background: var(--color-violet); + color: #fff; + font-family: var(--font-mono); + font-size: 14px; + transition: transform var(--dur-ui) var(--ease); +} +.skip-link:focus-visible { transform: none; } +main:focus { outline: none; } + +/* ---------------- utilidades ---------------- */ +.container { + width: 100%; + max-width: var(--container-max); + margin-inline: auto; + padding-inline: var(--container-pad); +} +.mono { font-family: var(--font-mono); } +.meta { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--color-muted); +} +.serif { font-family: var(--font-serif); } + +/* eyebrow de sección — mono, violeta, tamaño de la casa */ +.eyebrow { + font-family: var(--font-mono); + font-size: 16px; + letter-spacing: 0.03em; + color: var(--color-violet-2); /* texto pequeño -> violeta claro (AA) */ + margin-bottom: var(--space-6); +} + +.hairline-top { border-top: 1px solid var(--color-line); } +.hairline-bottom { border-bottom: 1px solid var(--color-line); } + +/* índice de sección: hairline superior + número violeta + etiqueta mono */ +.sechead { + display: flex; + align-items: baseline; + gap: var(--space-4); + border-top: 1px solid var(--color-line); + padding-top: var(--space-4); + margin-bottom: var(--space-10); + font-family: var(--font-mono); + font-size: 15px; +} +.sechead__n { color: var(--color-violet-2); font-weight: 700; } +.sechead__label { color: var(--color-muted); } + +/* cursor de escritura parpadeante al final de los H2 */ +.caret-type::after { + content: '_'; + font-family: var(--font-mono); + color: var(--color-violet); + font-weight: 400; + margin-left: 0.08em; + animation: caret-blink 1.05s steps(1) infinite; +} +@keyframes caret-blink { 50% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .caret-type::after { animation: none; } } + +/* reveal al scroll */ +[data-reveal] { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s var(--ease), transform 0.6s var(--ease); +} +[data-reveal].is-in { opacity: 1; transform: none; } +[data-reveal-stagger] > * { + opacity: 0; + transform: translateY(16px); + transition: opacity 0.5s var(--ease), transform 0.5s var(--ease); +} +[data-reveal-stagger] > *.is-in { opacity: 1; transform: none; } +@media (prefers-reduced-motion: reduce) { + [data-reveal], [data-reveal-stagger] > * { opacity: 1; transform: none; } +} + +/* ---------------- sistema de botones ---------------- */ +/* cuadrados (radio 0), 46px, JetBrains Mono. interactivo -> violeta. */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 46px; + padding: 0 28px; + border: 1px solid transparent; + border-radius: 0; + cursor: pointer; + text-decoration: none; + line-height: 1; + white-space: nowrap; + font-family: var(--font-mono); + font-weight: 500; + font-size: 14px; + letter-spacing: 0.01em; + transition: background var(--dur-micro) var(--ease), + color var(--dur-micro) var(--ease), border-color var(--dur-micro) var(--ease); +} +/* primario: relleno violeta -> hover/active blanco */ +.btn-primary, .btn--primary { + background: var(--color-violet); + border-color: var(--color-violet); + color: #fff; +} +.btn-primary:hover, .btn-primary:active, +.btn--primary:hover, .btn--primary:active { + background: #fff; border-color: #fff; color: var(--color-bg); +} +/* secundario: relleno blanco -> hover/active violeta */ +.btn-secondary { + background: var(--color-text); + border-color: var(--color-text); + color: var(--color-bg); +} +.btn-secondary:hover, .btn-secondary:active { + background: var(--color-violet); border-color: var(--color-violet); color: #fff; +} +/* fantasma: solo borde -> hover violeta */ +.btn-ghost { + background: transparent; + border-color: var(--color-line); + color: var(--color-text); +} +.btn-ghost:hover, .btn-ghost:active { + border-color: var(--color-violet); color: var(--color-violet-2); +} + +/* ---------------- campos de formulario ---------------- */ +.field { + font-family: var(--font-sans); + font-size: 16px; + background: rgba(125, 125, 125, 0.04); + border: 1px solid var(--color-line); + color: var(--color-text); + padding: 0 16px; + height: 46px; + transition: border-color var(--dur-micro) var(--ease); +} +.field:focus, .field:focus-within { outline: none; border-color: var(--color-violet); } +.field::placeholder { color: #6a6a6e; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +@media (max-width: 720px) { body { font-size: 16px; } } + +/* En móvil, `background-attachment: fixed` obliga al navegador a repintar los + dos radiales en cada frame de scroll (en iOS ni siquiera se comporta como + fixed). Se pasa a scroll: visualmente casi idéntico, sin el coste. */ +@media (max-width: 860px) { + body { background-attachment: scroll; } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..8f6d34f --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,88 @@ +/* ============================================ + NaN — Design tokens (fuente de verdad) + Sistema vigente: off-black + monoacento violeta, + Archivo (display y cuerpo, variable con ejes wdth+wght) + JetBrains Mono + (técnico). Ojo con los nombres: --font-serif NO es una serif, es la misma + Archivo que --font-sans; el nombre viene de una iteración con Inria Serif y + se conserva para no tocar todos los componentes de golpe. + Solo modo oscuro. Esquina recta (radio 0). Regla: interactivo -> violeta. + Hex sueltos en código = prohibido: todo color pasa por aquí. + ============================================ */ +:root { + /* ---------- color ---------- */ + --color-bg: #0B0B0C; /* off-black — base de todo */ + --color-surface: #121214; /* cards/paneles sobre el negro */ + --color-text: #FFFFFF; + --color-body: #C9C9CC; /* cuerpo de texto secundario */ + --color-muted: #9A9A9E; /* labels, captions */ + + --color-black: #000000; /* negro puro (base brandbook): footer, contraste duro */ + + /* REGLA DE CONTRASTE (WCAG AA sobre --color-bg): + --color-violet = 3.43:1 -> SOLO rellenos, bordes, puntos, barras y + titulares grandes. Nunca texto pequeño. + --color-violet-2 = 5.39:1 -> todo el texto violeta (eyebrows, labels, + metadatos, hovers) y el anillo de foco. */ + --color-violet: #7D39EB; /* EL acento — único */ + --color-violet-2: #9B6BF0; /* violeta claro: texto violeta y hovers */ + --color-violet-dim: rgba(125, 57, 235, 0.14); + --color-link: #9B6BF0; /* violeta para texto/enlaces: AA sobre off-black (~5.4:1) */ + --color-link-line: rgba(155, 107, 240, 0.5); /* subrayado de enlaces de texto */ + + /* tamaños canónicos de titular: TODOS los h1 iguales, TODOS los h2 iguales */ + /* El suelo del H1 tiene que caber en un móvil de 320px. Con la Archivo + expandida a 850, "Community" mide ~355px a 46px de cuerpo y solo hay 272px + útiles: por eso desbordaba en /projects, /events y el CTA del footer. + A 34px entra con holgura y la curva 8.5vw es la misma de siempre. */ + --fs-h1: clamp(34px, 8.5vw, 96px); + --fs-h2: clamp(28px, 4.5vw, 46px); + --fs-h3: 22px; + + --color-line: rgba(255, 255, 255, 0.16); /* hairlines */ + --pulse: rgba(125, 57, 235, 0.55); + + /* ---------- tipografía ---------- */ + /* Archivo variable (wdth+wght) para display y cuerpo; mono para lo de siempre. */ + /* 'Archivo Variable' es el nombre que declara @fontsource-variable/archivo */ + --font-serif: 'Archivo Variable', 'Archivo', system-ui, sans-serif; /* titulares/display */ + --font-sans: 'Archivo Variable', 'Archivo', system-ui, sans-serif; /* cuerpo + UI */ + --font-mono: 'JetBrains Mono', ui-monospace, monospace; /* nav, botones, labels, datos */ + + /* ---------- spacing (base 8) ---------- */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-24: 6rem; + --space-32: 8rem; + + /* ---------- layout ---------- */ + --container-max: 1560px; + --container-pad: clamp(24px, 4vw, 72px); + --nav-h: 5rem; + + /* ---------- radios / motion ---------- */ + --radius-ui: 0; /* esquina recta en toda la UI */ + --ease: cubic-bezier(.22, .9, .24, 1); + --dur-micro: .2s; /* hovers */ + --dur-ui: .28s; /* transiciones interactivo->violeta */ + + /* ---------- alias legacy (secciones aún no migradas) ---------- */ + --color-text-dim: var(--color-body); + --color-hairline: var(--color-line); + --font-display: var(--font-sans); + --w-display: 400; + --color-snow: #EFEEEB; + --color-snow-2: #E3E1DC; + --color-snow-ink: #101010; + --color-snow-dim: rgba(16, 16, 16, 0.62); + --color-snow-hairline: rgba(16, 16, 16, 0.16); + --color-bone: #E8E6E1; + --color-ink: #141414; +} diff --git a/src/tests/landing/Founder.test.ts b/src/tests/landing/Founder.test.ts deleted file mode 100644 index 1a67a82..0000000 --- a/src/tests/landing/Founder.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { t, tArr } from '../../lib/i18n'; - -describe('Founder', () => { - describe('i18n data integrity', () => { - test('English heading mentions both founders by name', () => { - const heading = t('founder.heading', 'en'); - expect(heading).toContain('Cristian Córdova'); - expect(heading).toContain('Borja Perez'); - }); - - test('Spanish heading mentions both founders by name', () => { - const heading = t('founder.heading', 'es'); - expect(heading).toContain('Cristian Córdova'); - expect(heading).toContain('Borja Perez'); - }); - - test('English company intro mentions Helmcode with link', () => { - const intro = t('founder.companyIntro', 'en'); - expect(intro).toContain('Helmcode'); - expect(intro).toContain('helmcode.com'); - expect(intro).toContain('inference infrastructure'); - }); - - test('Spanish company intro mentions Helmcode with link', () => { - const intro = t('founder.companyIntro', 'es'); - expect(intro).toContain('Helmcode'); - expect(intro).toContain('helmcode.com'); - expect(intro).toContain('infraestructura de inferencia'); - }); - - test('Founder bio contains Helmcode link', () => { - const bio = tArr('founder.bio', 'en'); - const helmcodeLink = bio.find((line) => line.includes('helmcode.com')); - expect(helmcodeLink).toBeDefined(); - expect(helmcodeLink).toContain('target="_blank"'); - expect(helmcodeLink).toContain('rel="noopener noreferrer"'); - }); - - test('Founder quote is non-empty meaningful text', () => { - const quote = t('founder.quote', 'en'); - expect(quote.length).toBeGreaterThan(50); - expect(quote).toContain('NaN'); - }); - - test('All founder link labels exist in both locales', () => { - for (const key of ['twitter', 'linkedin', 'github']) { - expect(t(`founder.links.${key}`, 'en')).toBeTruthy(); - expect(t(`founder.links.${key}`, 'es')).toBeTruthy(); - } - }); - }); -}); \ No newline at end of file diff --git a/src/tests/landing/LanguageSwitcher.test.ts b/src/tests/landing/LanguageSwitcher.test.ts deleted file mode 100644 index 942e01c..0000000 --- a/src/tests/landing/LanguageSwitcher.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -// LanguageSwitcher test: reads the component source and verifies the logic -// is correct (defaults to English, toggles to Spanish on click). -// -// We test the source rather than rendering because Astro SSR would pull in -// the full layout tree. Source-level assertion is sufficient and prevents -// drift — if someone changes the default locale, this test will fail. - -const here = dirname(fileURLToPath(import.meta.url)); -const componentPath = resolve(here, '../../components/landing/LanguageSwitcher.astro'); -const source = readFileSync(componentPath, 'utf-8'); - -describe('LanguageSwitcher', () => { - test('defaults to English (no lang param)', () => { - // The component should default to 'en' when no ?lang param is present - expect(source).toContain("=== 'es' ? 'es' : 'en'"); - }); - - test('shows "ES" button when on English page', () => { - // When currentLang is 'en', the label should be 'ES' - expect(source).toContain("currentLang === 'en' ? 'ES' : 'EN'"); - }); - - test('links to ?lang=es when on English page', () => { - // When targetLang is 'es', the href should be '?lang=es' - expect(source).toContain("targetLang === 'en' ? '?lang=en' : '?lang=es'"); - }); - - test('shows "EN" button when on Spanish page', () => { - // When currentLang is 'es', the label should be 'EN' - expect(source).toContain("currentLang === 'en' ? 'ES' : 'EN'"); - }); - - test('links to ?lang=en when on Spanish page', () => { - // When targetLang is 'en', the href should be '?lang=en' - expect(source).toContain("targetLang === 'en' ? '?lang=en' : '?lang=es'"); - }); -}); \ No newline at end of file diff --git a/src/tests/landing/NavLanguage.test.ts b/src/tests/landing/NavLanguage.test.ts new file mode 100644 index 0000000..5617145 --- /dev/null +++ b/src/tests/landing/NavLanguage.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { getLocale, switchLocalePath } from '../../lib/i18n'; + +/** + * Sucede a LanguageSwitcher.test.ts. + * + * El switcher suelto que flotaba en una esquina desapareció con la landing + * anterior; ahora el selector de idioma vive en la Nav del rediseño, que pinta + * los dos idiomas y marca el activo en lugar de alternar una sola etiqueta. + * + * Se comprueban dos cosas distintas: el comportamiento, contra las funciones + * reales de lib/i18n, y que la Nav no se cablee a mano al margen de ellas. + * + * El idioma vive en la ruta, no en un query param. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(resolve(here, '../../components/nan/Nav.astro'), 'utf-8'); + +describe('Nav — selector de idioma', () => { + describe('comportamiento', () => { + test('desde una página inglesa cruza a español', () => { + expect(getLocale('/community')).toBe('en'); + expect(switchLocalePath('/community', 'es')).toBe('/es/community'); + }); + + test('desde una página española cruza a inglés', () => { + expect(getLocale('/es/community')).toBe('es'); + expect(switchLocalePath('/es/community', 'en')).toBe('/community'); + }); + + test('la raíz cruza en los dos sentidos', () => { + expect(switchLocalePath('/', 'es')).toBe('/es'); + expect(switchLocalePath('/es', 'en')).toBe('/'); + }); + }); + + describe('cableado del componente', () => { + test('resuelve el idioma con el helper, no leyendo la URL a mano', () => { + expect(source).toContain('getLang(Astro.url)'); + expect(source).not.toContain('searchParams'); + }); + + test('construye los destinos con switchLocalePath', () => { + expect(source).toContain("switchLocalePath(pathname, 'en')"); + expect(source).toContain("switchLocalePath(pathname, 'es')"); + }); + + test('pinta los dos idiomas y marca el activo', () => { + expect(source).toContain("'is-active': lang === 'en'"); + expect(source).toContain("'is-active': lang === 'es'"); + }); + + test('cada enlace declara su hreflang', () => { + expect(source).toContain('hreflang="en"'); + expect(source).toContain('hreflang="es"'); + }); + }); +}); diff --git a/src/tests/landing/WhoBehind.test.ts b/src/tests/landing/WhoBehind.test.ts new file mode 100644 index 0000000..3cb21c7 --- /dev/null +++ b/src/tests/landing/WhoBehind.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { t, tArr } from '../../lib/i18n'; + +/** + * Sucede a Founder.test.ts. Aquel comprobaba el bloque `founder` del + * diccionario, que servía a la sección de la landing anterior; el rediseño usa + * `nan.who` y aquello dejó de estar enganchado a nada. + * + * Lo que se garantiza sigue siendo lo mismo: que la comunidad aparece liderada + * por las dos personas y que Helmcode se nombra como la casa madre. + */ + +describe('WhoBehind — integridad del copy', () => { + for (const locale of ['en', 'es'] as const) { + describe(locale, () => { + test('el titular nombra a las dos personas', () => { + const title = t('nan.who.title', locale); + expect(title).toContain('Cristian Córdova'); + expect(title).toContain('Borja Perez'); + }); + + test('los dos tienen rol y ubicación', () => { + for (const key of ['cristianRole', 'cristianLoc', 'borjaRole', 'borjaLoc']) { + expect(t(`nan.who.${key}`, locale), `nan.who.${key}`).not.toBe(`nan.who.${key}`); + expect(t(`nan.who.${key}`, locale).length).toBeGreaterThan(0); + } + }); + + test('la bio de Cristian menciona Helmcode', () => { + const bio = tArr('nan.who.cristianBio', locale).join(' '); + expect(bio.length).toBeGreaterThan(0); + expect(bio).toContain('Helmcode'); + }); + + test('hay cita y quién la firma', () => { + expect(t('nan.who.quote', locale).length).toBeGreaterThan(20); + expect(t('nan.who.quoteBy', locale)).toContain('Cristian'); + }); + }); + } +}); diff --git a/src/tests/landing/projectsModal.test.ts b/src/tests/landing/projectsModal.test.ts new file mode 100644 index 0000000..b4d1fb1 --- /dev/null +++ b/src/tests/landing/projectsModal.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +/** + * El modal de /projects pinta datos que aportan los miembros al publicar su + * proyecto (nombre, descripción, tags, portada). + * + * Astro escapa los `data-*` al renderizar, pero al leerlos por `dataset` se + * recupera el texto original: si de ahí van a `innerHTML`, vuelven a + * interpretarse como marcado. Y la CSP de NanBase permite 'unsafe-inline', así + * que un handler inline ejecutaría. La galería anterior (ProjectsGallery.tsx) + * lo tenía cubierto por ser Preact; el port a vanilla perdió esa garantía. + * + * Esto es una guarda de texto, no una prueba de comportamiento: no hay entorno + * DOM en la suite. Verifica que el patrón peligroso no vuelva. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(resolve(here, '../../pages/projects.astro'), 'utf-8'); + +/** Solo el bloque del script del cliente, no el marcado del componente. */ +const clientScript = source.slice(source.indexOf('')).toBe(''); + expect(safeUrl('vbscript:msgbox(1)')).toBe(''); + expect(safeUrl('file:///etc/passwd')).toBe(''); + }); + + test('descarta lo vacío y lo que no es una URL absoluta', () => { + expect(safeUrl(undefined)).toBe(''); + expect(safeUrl('')).toBe(''); + expect(safeUrl('/relativa')).toBe(''); + expect(safeUrl('example.com')).toBe(''); + }); + + test('no se cuela por mayúsculas', () => { + expect(safeUrl('JavaScript:alert(1)')).toBe(''); + expect(safeUrl('HTTPS://example.com')).toBe('HTTPS://example.com'); + }); +}); + +describe('safeImage', () => { + test('exige https: una portada en http la bloquearía el navegador', () => { + expect(safeImage('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png'); + expect(safeImage('http://cdn.example.com/a.png')).toBe(''); + }); + + test('descarta esquemas peligrosos y lo vacío', () => { + expect(safeImage('javascript:alert(1)')).toBe(''); + expect(safeImage(undefined)).toBe(''); + }); +}); + +describe('slugify', () => { + test('pasa a minúsculas y une con guiones', () => { + expect(slugify('A Project Name')).toBe('a-project-name'); + }); + + test('quita los acentos', () => { + expect(slugify('Evaluación de agentes')).toBe('evaluacion-de-agentes'); + }); + + test('no deja guiones sueltos en los extremos', () => { + expect(slugify(' ¡Hola! ')).toBe('hola'); + expect(slugify('---a---')).toBe('a'); + }); + + test('aguanta lo vacío y lo que no deja nada', () => { + expect(slugify('')).toBe(''); + expect(slugify('!!!')).toBe(''); + }); +}); + +describe('byStars', () => { + test('ordena descendente', () => { + const sorted = [project({ stars: 1 }), project({ stars: 9 }), project({ stars: 5 })].sort(byStars); + expect(sorted.map((p) => p.stars)).toEqual([9, 5, 1]); + }); + + test('un proyecto sin stars no rompe el orden', () => { + // El bug: sin el `?? 0` el comparador devuelve NaN y el orden queda + // indefinido para TODA la lista, no solo para el proyecto sin stars. + const sorted = [project({ stars: 3 }), project({}), project({ stars: 7 })].sort(byStars); + expect(sorted.map((p) => p.stars ?? 0)).toEqual([7, 3, 0]); + }); +}); + +describe('toFloppyProject', () => { + test('el destino prefiere la app y cae al repo', () => { + expect(toFloppyProject(project({ appUrl: 'https://app.example', repoUrl: 'https://repo.example' })).url) + .toBe('https://app.example'); + expect(toFloppyProject(project({ repoUrl: 'https://repo.example' })).url).toBe('https://repo.example'); + }); + + test('sin destinos válidos la url queda vacía', () => { + expect(toFloppyProject(project({ appUrl: 'javascript:alert(1)' })).url).toBe(''); + expect(toFloppyProject(project({})).url).toBe(''); + }); + + test('cover solo es true con una portada https real', () => { + expect(toFloppyProject(project({ imageUrl: 'https://cdn.example/a.png' })).cover).toBe(true); + expect(toFloppyProject(project({ imageUrl: 'http://cdn.example/a.png' })).cover).toBe(false); + expect(toFloppyProject(project({})).cover).toBe(false); + }); + + test('el slug cae al handle si no hay nombre', () => { + expect(toFloppyProject(project({ name: '', handle: 'jane-doe' })).slug).toBe('jane-doe'); + }); + + test('rellena los huecos en vez de dejar undefined en el marcado', () => { + const p = toFloppyProject(project({})); + expect(p.descripcion).toBe(''); + expect(p.tags).toEqual([]); + expect(p.stars).toBe(0); + }); +}); + +describe('fetchProjects', () => { + const ok = (projects: ApiProject[]) => + vi.fn(async () => new Response(JSON.stringify({ projects }), { status: 200 })); + + test('pide el límite que se le pasa y manda el origin', async () => { + const fetchImpl = ok([]); + await fetchProjects('https://api.example/', { limit: 16, fetchImpl: fetchImpl as unknown as typeof fetch }); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + // La barra final del base no debe duplicarse. + expect(url).toBe('https://api.example/api/projects?limit=16'); + expect((init.headers as Record).origin).toBe('https://nan.builders'); + }); + + test('devuelve los proyectos ordenados y normalizados', async () => { + const fetchImpl = ok([ + project({ id: 1, name: 'Low', stars: 2 }), + project({ id: 2, name: 'High', stars: 30, appUrl: 'https://a.example' }), + ]); + const result = await fetchProjects('https://api.example', { fetchImpl: fetchImpl as unknown as typeof fetch }); + + expect(result.map((p) => p.nombre)).toEqual(['High', 'Low']); + expect(result[0].url).toBe('https://a.example'); + }); + + test('recorta al límite aunque la API lo ignore', async () => { + // cloud-api devuelve hoy todos los proyectos pase lo que pase en `limit`, + // así que si no se recortara aquí la home crecería sola. + const many = Array.from({ length: 17 }, (_, i) => project({ id: i, name: `P${i}`, stars: i })); + const result = await fetchProjects('https://api.example', { + limit: 4, + fetchImpl: ok(many) as unknown as typeof fetch, + }); + + expect(result).toHaveLength(4); + // Y recorta DESPUÉS de ordenar: se queda con los más estrellados. + expect(result.map((p) => p.stars)).toEqual([16, 15, 14, 13]); + }); + + test('lista vacía si la API responde con error', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 })); + await expect(fetchProjects('https://api.example', { fetchImpl: fetchImpl as unknown as typeof fetch })) + .resolves.toEqual([]); + }); + + test('lista vacía si el fetch lanza (red caída o timeout)', async () => { + const fetchImpl = vi.fn(async () => { throw new Error('network'); }); + await expect(fetchProjects('https://api.example', { fetchImpl: fetchImpl as unknown as typeof fetch })) + .resolves.toEqual([]); + }); + + test('lista vacía si el JSON no tiene la forma esperada', async () => { + for (const body of ['{"projects":null}', '{}', 'no soy json', '[]']) { + const fetchImpl = vi.fn(async () => new Response(body, { status: 200 })); + await expect(fetchProjects('https://api.example', { fetchImpl: fetchImpl as unknown as typeof fetch })) + .resolves.toEqual([]); + } + }); + + test('sin base url no llama a nadie', async () => { + const fetchImpl = ok([]); + await expect(fetchProjects('', { fetchImpl: fetchImpl as unknown as typeof fetch })).resolves.toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('lleva un timeout, para no colgar la página si cloud-api va lento', async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response(JSON.stringify({ projects: [] }), { status: 200 }); + }); + await fetchProjects('https://api.example', { fetchImpl: fetchImpl as unknown as typeof fetch }); + expect(fetchImpl).toHaveBeenCalled(); + }); +}); diff --git a/src/tests/lib/sitemap.test.ts b/src/tests/lib/sitemap.test.ts new file mode 100644 index 0000000..0264f90 --- /dev/null +++ b/src/tests/lib/sitemap.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +// astro:content es un módulo virtual que solo existe dentro del build de Astro, +// así que se moquea antes de importar la ruta. +const getCollectionMock = vi.fn(); +vi.mock('astro:content', () => ({ getCollection: getCollectionMock })); + +const { GET } = await import('../../pages/sitemap.xml'); + +/** + * El sitemap es la única declaración de qué queremos indexado, y se escribió a + * mano porque @astrojs/sitemap no recoge rutas SSR. Hasta ahora no tenía ningún + * test, y es justo donde un error no se ve: sale mal y nadie se entera hasta que + * el buscador indexa lo que no debía. + */ + +const SITE = 'https://nan.builders'; + +async function sitemap(docs: { id: string }[] = []) { + getCollectionMock.mockResolvedValue(docs); + const res = await GET({} as Parameters[0]); + return { res: res as Response, xml: await (res as Response).text() }; +} + +const locs = (xml: string) => [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); + +beforeEach(() => getCollectionMock.mockReset()); + +describe('sitemap.xml', () => { + test('se sirve como XML', async () => { + const { res } = await sitemap(); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/xml'); + }); + + test('declara el namespace de xhtml, que hace falta para los alternates', async () => { + const { xml } = await sitemap(); + expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"'); + }); + + test('cada ruta bilingüe aparece en los dos idiomas', async () => { + const { xml } = await sitemap(); + const urls = locs(xml); + + for (const path of ['/events', '/projects', '/community', '/privacy', '/terms', '/cookies']) { + expect(urls).toContain(`${SITE}${path}`); + expect(urls).toContain(`${SITE}/es${path}`); + } + expect(urls).toContain(`${SITE}/`); + expect(urls).toContain(`${SITE}/es`); + }); + + test('el hackatón queda fuera entero, en los dos idiomas', async () => { + const { xml } = await sitemap(); + const urls = locs(xml); + + // El evento ya pasó y ninguna página lo enlaza: o entrada en la nav, o + // fuera del sitemap con noindex. Lo que no vale es declararlo indexable + // sin que se pueda llegar. Las cinco pantallas van con noindex. + for (const path of ['/hackaton', '/hackaton/me', '/hackaton/submission', '/hackaton/leaderboard', '/hackaton/projects']) { + expect(urls).not.toContain(`${SITE}${path}`); + expect(urls).not.toContain(`${SITE}/es${path}`); + } + }); + + test('no cuela el 404 ni las rutas de API', async () => { + const { xml } = await sitemap(); + const urls = locs(xml); + + // noindex. + expect(urls.some((u) => u.includes('/404'))).toBe(false); + // No son páginas. + expect(urls.some((u) => u.includes('/api/'))).toBe(false); + }); + + test('solo la raíz lleva barra final, y el resto no', async () => { + const { xml } = await sitemap(); + for (const url of locs(xml)) { + if (url === `${SITE}/`) continue; + expect(url.endsWith('/')).toBe(false); + } + }); + + test('cada URL bilingüe declara sus dos alternates y el x-default', async () => { + const { xml } = await sitemap(); + // Se toma un concreto y se comprueba su bloque, no el documento entero. + const block = xml.match(/https:\/\/nan\.builders\/events<\/loc>(.*?)<\/url>/)?.[1] ?? ''; + + expect(block).toContain(`hreflang="en" href="${SITE}/events"`); + expect(block).toContain(`hreflang="es" href="${SITE}/es/events"`); + expect(block).toContain(`hreflang="x-default" href="${SITE}/events"`); + }); + + test('el x-default apunta siempre al inglés, también en la URL española', async () => { + const { xml } = await sitemap(); + const block = xml.match(/https:\/\/nan\.builders\/es\/events<\/loc>(.*?)<\/url>/)?.[1] ?? ''; + expect(block).toContain(`hreflang="x-default" href="${SITE}/events"`); + }); + + test('la documentación entra desde la colección, y sin alternates (solo existe en inglés)', async () => { + const { xml } = await sitemap([{ id: 'intro' }, { id: 'apps' }]); + const urls = locs(xml); + + expect(urls).toContain(`${SITE}/docs/intro`); + expect(urls).toContain(`${SITE}/docs/apps`); + + const block = xml.match(/https:\/\/nan\.builders\/docs\/intro<\/loc>(.*?)<\/url>/)?.[1] ?? ''; + expect(block).not.toContain('hreflang'); + }); + + test('sin documentos, el sitemap sigue siendo válido', async () => { + const { xml } = await sitemap([]); + expect(xml).toContain(' { + const { xml } = await sitemap([{ id: 'intro' }]); + const urls = locs(xml); + expect(urls.length).toBe(new Set(urls).size); + }); +}); diff --git a/src/components/landing/waitlistForm.helpers.test.ts b/src/tests/lib/waitlistClient.test.ts similarity index 58% rename from src/components/landing/waitlistForm.helpers.test.ts rename to src/tests/lib/waitlistClient.test.ts index 2c7a0fd..89f019c 100644 --- a/src/components/landing/waitlistForm.helpers.test.ts +++ b/src/tests/lib/waitlistClient.test.ts @@ -4,12 +4,9 @@ import { isValidEmail, isWaitlistRegion, parseWaitlistResponse, - successMessage, - errorMessage, - getTranslations, -} from './waitlistForm.helpers'; - -const esT = getTranslations('es'); + waitlistErrorText, + waitlistSuccessText, +} from '../../lib/waitlistClient'; describe('normalizeEmail', () => { it('trims and lowercases', () => { @@ -154,42 +151,66 @@ describe('parseWaitlistResponse', () => { }); }); -describe('successMessage', () => { - it('uses the registered message for EU signups', () => { - const msg = successMessage({ - ok: true, - position: 7, - total: 7, - status: 'registered', - region: 'EU', - }, esT); - expect(msg).toContain('orden de llegada'); +describe('waitlistErrorText', () => { + const m = { + okRegistered: 'ok', okInterest: 'interes', okPosition: 'puesto', okText: 'texto', + errEmail: 'email mal', errRegion: 'elige region', errRateLimited: 'espera un minuto', + errNetwork: 'sin conexion', errGeneric: 'algo se rompio', + }; + + it('distingue el rate limit del error genérico', () => { + // Mostrar "algo se ha roto" para un rate limit es mentira y no dice qué hacer. + expect(waitlistErrorText('rate_limited', m).text).toBe('espera un minuto'); + expect(waitlistErrorText('server_error', m).text).toBe('algo se rompio'); + expect(waitlistErrorText('rate_limited', m).text).not.toBe(waitlistErrorText('server_error', m).text); }); - it('uses an interest message mentioning the region', () => { - const msg = successMessage({ - ok: true, - position: 0, - total: 0, - status: 'interest', - region: 'LATAM', - }, esT); - expect(msg).toContain('LATAM'); + it('distingue el fallo de red del fallo del servidor', () => { + expect(waitlistErrorText('network_error', m).text).toBe('sin conexion'); }); -}); -describe('errorMessage', () => { - it('returns a distinct message per error', () => { - const messages = new Set(); - for (const err of [ - 'invalid_email', - 'invalid_region', - 'rate_limited', - 'network_error', - 'server_error', - ] as const) { - messages.add(errorMessage({ ok: false, error: err }, esT)); + it('solo devuelve el foco al campo cuando el problema es el email', () => { + expect(waitlistErrorText('invalid_email', m)).toEqual({ text: 'email mal', focusEmail: true }); + for (const e of ['invalid_region', 'rate_limited', 'network_error', 'server_error'] as const) { + expect(waitlistErrorText(e, m).focusEmail).toBe(false); } - expect(messages.size).toBe(5); + }); + + it('cada código tiene un texto, ninguno cae en blanco', () => { + for (const e of ['invalid_email', 'invalid_region', 'rate_limited', 'network_error', 'server_error'] as const) { + expect(waitlistErrorText(e, m).text.length).toBeGreaterThan(0); + } + }); +}); + +describe('waitlistSuccessText', () => { + const m = { + okRegistered: 'Estás dentro.', okInterest: 'Aún no abrimos ahí.', okPosition: 'puesto', + okText: 'Te aprobamos en unos días.', errEmail: '', errRegion: '', errRateLimited: '', + errNetwork: '', errGeneric: '', + }; + + it('una región sin apertura no enseña puesto: no hay puesto que enseñar', () => { + const text = waitlistSuccessText( + { ok: true, position: 0, total: 0, status: 'interest', region: 'LATAM' }, m, + ); + expect(text).toBe('Aún no abrimos ahí.'); + expect(text).not.toContain('puesto'); + }); + + it('un alta en EU enseña la posición, rellenada a tres cifras', () => { + const text = waitlistSuccessText( + { ok: true, position: 7, total: 450, status: 'registered', region: 'EU' }, m, + ); + expect(text).toContain('puesto 007 / 450'); + expect(text).toContain('Estás dentro.'); + }); + + it('sin cifras válidas no inventa un puesto', () => { + const text = waitlistSuccessText( + { ok: true, position: 0, total: 0, status: 'registered', region: 'EU' }, m, + ); + expect(text).not.toContain('puesto'); + expect(text).toContain('Estás dentro.'); }); });