diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..90ff53b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Lockfile +# +# merge=binary tells git to never line-merge this file. A lockfile that git +# stitched together from two sides is not a valid lockfile — it just looks like +# one. On conflict git now stops and keeps our side, so the resolution has to be +# a deliberate `yarn install`, not a silent guess. +# +# linguist-generated collapses it in GitHub diffs, so a 4000-line lockfile churn +# stops burying the actual review. +yarn.lock merge=binary linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c59233b..8c2dc45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/app/commit-plus/page.tsx b/app/commit-plus/page.tsx new file mode 100644 index 0000000..265f7aa --- /dev/null +++ b/app/commit-plus/page.tsx @@ -0,0 +1,567 @@ +import type { Metadata } from 'next' +import { + ArrowRight, + BookMarked, + GitPullRequest, + MessageCircle, + Trophy, + Users, + Video, + Zap, +} from 'lucide-react' +import { FeatureCard, Typography, buttonVariants } from '@commitpt/design-system' +import Header from '@/src/components/Header' +import Footer from '@/src/components/Footer' +import FAQ from '@/src/components/FAQ' +import { selectFaqs } from '@/src/data/faqs' +import { WHOP_COMMIT_PLUS_URL, WHOP_FREE_DISCORD_URL } from '@/src/lib/links' + +const DESCRIPTION = + 'Workshops e calls ao vivo exclusivas, revisões de código, projetos internos e acesso antecipado à plataforma. A comunidade gratuita no Discord continua gratuita.' + +export const metadata: Metadata = { + title: 'Commit+ O plano premium da CommitPT', + description: DESCRIPTION, + alternates: { + canonical: 'https://www.commitpt.com/commit-plus', + }, + openGraph: { + type: 'website', + url: 'https://www.commitpt.com/commit-plus', + title: 'Commit+ O plano premium da CommitPT', + description: DESCRIPTION, + siteName: 'CommitPT', + images: [{ url: '/commit_3_512w.webp', width: 512, height: 512, alt: 'CommitPT' }], + locale: 'pt_PT', + }, + twitter: { + card: 'summary', + title: 'Commit+ O plano premium da CommitPT', + description: DESCRIPTION, + images: ['/commit_3_512w.webp'], + }, + robots: { + index: true, + follow: true, + googleBot: { index: true, follow: true }, + }, +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function CommitPlusPage() { + return ( +
+
+
+ + + + + + +
+
+ ) +} + +// ── Shared CTAs ─────────────────────────────────────────────────────────────── +// +// Both entry points go to Whop — see src/lib/links.ts for why the free tier +// does too. + +function CtaButtons({ className = '' }: { className?: string }) { + return ( +
+ + Adere ao Commit+ + + + + + Experimenta o Discord Grátis + +
+ ) +} + +// ── Hero ────────────────────────────────────────────────────────────────────── + +function HeroSection() { + return ( +
+
+ + {'01 // Commit+'} + + + + Commit + + + + + + A comunidade gratuita no Discord vai continuar a existir sempre. O Commit+ é para quem + quer ir mais fundo. + + + + Não pagas para entrar numa comunidade, pagas para ajudar a construir algo que beneficia + toda a gente, incluindo tu. Workshops, calls ao vivo, projetos internos e contacto direto + com quem já passou por onde estás. + + + +
+
+ ) +} + +// ── Benefits ────────────────────────────────────────────────────────────────── + +const BENEFITS = [ + { + icon: Video, + title: 'Workshops e calls exclusivas', + desc: 'Sessões ao vivo sobre temas práticos, não apresentações genéricas. Ficam gravadas para quem não puder estar presente.', + }, + { + icon: GitPullRequest, + title: 'Revisões de código a sério', + desc: 'Há hábitos de código que cultivas há meses e que ninguém te apontou. Aqui alguém aponta antes de ser numa entrevista.', + }, + { + icon: BookMarked, + title: 'Recursos e materiais premium', + desc: 'Curados por profissionais da área e mantidos atualizados. Não é uma lista de links que ninguém revê há dois anos.', + }, + { + icon: Users, + title: 'Projetos internos da comunidade', + desc: 'Colaboras em projetos reais com outros membros. A pasta de projetos a meio deixa de crescer.', + }, + { + icon: Trophy, + title: 'Contacto próximo com seniores', + desc: 'Acesso direto à equipa e a engenheiros com anos de experiência. O mercado deixa de ser uma caixa negra.', + }, + { + icon: Zap, + title: 'Acesso antecipado à plataforma', + desc: 'Entras no app.commitpt.com antes do lançamento público.', + }, +] + +function BenefitsSection() { + return ( +
+
+
+ + {'02 // Benefícios'} + + + O que o Commit+ acrescenta. + + + Tudo o que está na comunidade gratuita continua lá. Isto é a camada extra. + +
+ +
+ {BENEFITS.map((benefit) => ( +
+
+
+ ) +} + +// ── Diff signature ──────────────────────────────────────────────────────────── + +// A decorative terminal. The point is the shape of a git workflow, not a +// faithful simulation — the session shows branch, diff, commit and push, and +// the prompt carries the branch so it flips to commit-plus after the checkout. +// Deliberately no diff body: it made the block long without earning it. + +const CMD = 'text-syntax-purple' // git itself +const SUB = 'text-syntax-blue' // subcommand +const FLAG = 'text-foreground' // flags and plain args +const REF = 'text-syntax-green' // refs, paths, quoted strings + +type Tone = 'meta' | 'info' + +interface Segment { + text: string + className: string +} + +type TerminalLine = + | { kind: 'blank' } + | { kind: 'command'; branch: string; segments: Segment[] } + | { kind: 'output'; tone: Tone; text: string } + +const SESSION: TerminalLine[] = [ + { + kind: 'command', + branch: 'master', + segments: [ + { text: 'git', className: CMD }, + { text: ' checkout', className: SUB }, + { text: ' -b', className: FLAG }, + { text: ' commit-plus', className: REF }, + ], + }, + { kind: 'output', tone: 'info', text: "Switched to a new branch 'commit-plus'" }, + { kind: 'blank' }, + + { + kind: 'command', + branch: 'commit-plus', + segments: [ + { text: 'git', className: CMD }, + { text: ' diff', className: SUB }, + { text: ' --no-index', className: FLAG }, + { text: ' gratuito.md commit-plus.md', className: REF }, + ], + }, + { kind: 'blank' }, + + { + kind: 'command', + branch: 'commit-plus', + segments: [ + { text: 'git', className: CMD }, + { text: ' commit', className: SUB }, + { text: ' -am', className: FLAG }, + { text: ' "feat: investir na carreira"', className: REF }, + ], + }, + { kind: 'output', tone: 'info', text: '[commit-plus 9e21b0c] feat: investir na carreira' }, + { kind: 'output', tone: 'info', text: ' 1 file changed, 6 insertions(+)' }, + { kind: 'blank' }, + + { + kind: 'command', + branch: 'commit-plus', + segments: [ + { text: 'git', className: CMD }, + { text: ' push', className: SUB }, + { text: ' origin', className: FLAG }, + { text: ' commit-plus', className: REF }, + ], + }, + { kind: 'output', tone: 'meta', text: 'To github.com:commitpt/carreira.git' }, + { kind: 'output', tone: 'info', text: ' 7c4a1f2..9e21b0c commit-plus -> commit-plus' }, +] + +const TONE_STYLES: Record = { + meta: 'text-muted-foreground/50', + info: 'text-muted-foreground/80', +} + +function DiffSection() { + return ( +
+
+
+ + {'03 // A diferença'} + + + A diferença, em diff. + + + Um branch, um commit, um push. É assim que se passa do plano gratuito para o Commit+ sem + perder nada do que já tinhas. + +
+ + + + {/* The CTA lands right where the metaphor closes, on the push. */} +
+ + Faz o teu push para o Commit+ + + + + + {'// A comunidade gratuita não perde nada. O Commit+ só adiciona.'} + +
+
+
+ ) +} + +function Prompt({ branch }: { branch: string }) { + return ( + + ~/commitpt{' '} + git:( + {branch} + ){' '} + ${' '} + + ) +} + +function TerminalRow({ line }: { line: TerminalLine }) { + if (line.kind === 'blank') return
+ + if (line.kind === 'command') { + return ( +
+ + {line.segments.map((segment, i) => ( + + {segment.text} + + ))} +
+ ) + } + + return
{line.text}
+} + +function UpgradeTerminal() { + return ( +
+
+
+
+
+ + ~/commitpt — git + +
+ + {/* + role="img" + aria-label: read out line by line this is punctuation soup, + and every benefit in it is already announced by the cards above and the + plan list below. tabIndex so keyboard users can still scroll the + overflow on narrow viewports. + */} +
+ {/* min-w-max so the highlighted rows keep their background across the + full scroll width, not just the visible box. */} +
+ {SESSION.map((line, i) => ( + + ))} + +
+ + {/* Idle prompt, waiting for the next command */} +
+ + +
+
+
+
+ ) +} + +// ── Pricing ─────────────────────────────────────────────────────────────────── +// +// No prices here on purpose. The price lives on Whop so the click is measured. + +interface Plan { + badge: string + title: string + description: string + features: string[] + ctaLabel: string + ctaHref: string + featured: boolean +} + +const PLANS: Plan[] = [ + { + badge: 'Gratuito', + title: 'Discord Grátis', + description: + 'Entra, faz perguntas, conhece pessoas e participa nas discussões. Sem pagar nada, para sempre.', + features: [ + 'perguntas sem julgamentos', + 'discussões técnicas e de arquitetura', + 'contacto com a comunidade', + 'anúncios e eventos abertos', + ], + ctaLabel: 'Entrar no Discord', + ctaHref: WHOP_FREE_DISCORD_URL, + featured: false, + }, + { + badge: 'Commit+', + title: 'Commit+', + description: + 'A camada extra para quem quer ir mais fundo. Subscrição mensal, cancelas quando quiseres.', + features: [ + 'workshops e calls ao vivo exclusivas', + 'revisões de código em projetos reais', + 'recursos e materiais premium', + 'projetos internos da comunidade', + 'contacto próximo com seniores', + 'acesso antecipado à plataforma', + ], + ctaLabel: 'Adere ao Commit+', + ctaHref: WHOP_COMMIT_PLUS_URL, + featured: true, + }, +] + +function PricingCard({ plan }: { plan: Plan }) { + const { featured } = plan + + return ( +
+ + + {plan.badge} + + + + {plan.title} + + + + {plan.description} + + +
+ +
    + {plan.features.map((feature) => ( +
  • + + {featured ? '+' : '·'} + + {feature} +
  • + ))} +
+ + + {plan.ctaLabel} + + +
+ ) +} + +function PricingSection() { + return ( +
+
+
+ + {'04 // Planos'} + + + Escolhe por onde entras. + + + Podes começar pelo Discord gratuito e subir quando fizer sentido. Sem contratos cancelas + quando quiseres. + +
+ +
+ {PLANS.map((plan) => ( + + ))} +
+
+
+ ) +} + +// ── Final CTA ───────────────────────────────────────────────────────────────── + +function CtaSection() { + return ( +
+
+ + {'06 // Junta-te a nós'} + + + Prova a ti próprio que vale a pena. + + + Se entras, participas durante um mês e decides que não é para ti, cancelas e pronto. Não + te pedimos justificações nem tentamos convencer-te a ficar. + + +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index b9eabfe..0fe661d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import dynamic from 'next/dynamic' +import { faqs } from '@/src/data/faqs' import Header from '@/src/components/Header' import Hero from '@/src/components/Hero' import ForWho from '@/src/components/ForWho' @@ -25,7 +26,12 @@ export default function Home() { - +
diff --git a/app/sitemap.ts b/app/sitemap.ts index 0d7265e..ac0c3da 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -31,6 +31,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'monthly', priority: 0.6, }, + { + url: `${BASE_URL}/commit-plus`, + lastModified: new Date(), + changeFrequency: 'monthly', + priority: 0.7, + }, { url: `${BASE_URL}/privacy`, lastModified: new Date(), diff --git a/package.json b/package.json index d4d7115..e5bcc4f 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,11 @@ "name": "commitpt-landing", "version": "0.1.0", "private": true, + "packageManager": "yarn@1.22.22", + "engines": { + "node": ">=24.0.0 <25.0.0", + "yarn": ">=1.22.0 <2.0.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -24,8 +29,8 @@ "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", "@tailwindcss/postcss": "^4.3.2", - "@types/node": "^26.0.0", - "@types/react": "^19.2.17", + "@types/node": "26.1.2", + "@types/react": "19.2.18", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7", "@typescript-eslint/parser": "^7", @@ -39,7 +44,7 @@ "sharp": "^0.35.3", "sharp-cli": "^5.2.0", "tailwindcss": "^4.3.2", - "typescript": "^6.0.3" + "typescript": "5.9.3" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/src/components/FAQ.tsx b/src/components/FAQ.tsx index 5d5b778..e1bb84d 100644 --- a/src/components/FAQ.tsx +++ b/src/components/FAQ.tsx @@ -7,184 +7,28 @@ import { buttonVariants, type FAQItem as FAQAccordionItem, } from '@commitpt/design-system' +import { blocksToPlainText, type Block, type FaqItem } from '@/src/data/faqs' +import { WHOP_FREE_DISCORD_URL } from '@/src/lib/links' // ── Types ───────────────────────────────────────────────────────────────────── -type Block = { type: 'p'; text: string } | { type: 'list'; items: string[] } - -interface FaqItem { - q: string - blocks: Block[] +interface FaqSectionProps { + /** Section eyebrow label, including the page's own section number. */ + eyebrow: string + heading: string + description: string + /** + * FAQ entries to show. The accordion and the JSON-LD both derive from this + * one list, so the structured data always describes what is on the page. + */ + items: FaqItem[] + /** + * Emit FAQPage JSON-LD. Turn off on pages that reuse questions already + * claimed elsewhere — the same Q&A should only be marked up once per site. + */ + withSchema?: boolean } -// ── Data ────────────────────────────────────────────────────────────────────── - -const faqs: FaqItem[] = [ - { - q: 'A comunidade é só para programadores experientes?', - blocks: [ - { - type: 'p', - text: 'Não. A CommitPT tem membros em todos os níveis — desde estudantes e programadores em início de carreira até engenheiros com anos de experiência em empresas internacionais.', - }, - { - type: 'p', - text: 'Isso é precisamente o que torna a comunidade útil. Quando tens dúvidas, há alguém que já passou pelo mesmo. Quando já sabes alguma coisa, partilhares esse conhecimento também te faz crescer.', - }, - { - type: 'p', - text: 'O único requisito é teres vontade de aprender e de contribuir para o crescimento dos outros.', - }, - ], - }, - { - q: 'O que encontro dentro da comunidade?', - blocks: [ - { - type: 'p', - text: 'A CommitPT não é um curso. É um ambiente onde o crescimento acontece através da interação, da colaboração e de projetos reais.', - }, - { type: 'p', text: 'Dentro da comunidade encontras, entre outras coisas:' }, - { - type: 'list', - items: [ - 'Workshops práticos sobre temas técnicos e de carreira', - 'Calls semanais ao vivo com outros membros', - 'Revisões de código e feedback honesto', - 'Discussões técnicas sobre arquitetura, ferramentas e boas práticas', - 'Projetos internos onde podes colaborar com outros membros', - 'Recursos e materiais curados por profissionais da área', - 'Contacto direto com engenheiros experientes', - 'Uma comunidade ativa onde podes fazer perguntas sem julgamentos', - ], - }, - ], - }, - { - q: 'Quanto tempo preciso de dedicar por semana?', - blocks: [ - { - type: 'p', - text: 'Não há um mínimo obrigatório. Cada pessoa participa ao seu próprio ritmo.', - }, - { - type: 'p', - text: 'Há membros que entram todos os dias, outros que aparecem algumas vezes por semana. O que retiras da comunidade está diretamente relacionado com o que investes — mas essa decisão é sempre tua.', - }, - { - type: 'p', - text: 'O importante é que quando precisares de feedback, de uma perspetiva diferente ou apenas de ver o que outros estão a construir, a comunidade está lá.', - }, - ], - }, - { - q: 'Como funcionam as calls e workshops?', - blocks: [ - { - type: 'p', - text: 'São sessões ao vivo, com foco em temas práticos. Não há apresentações genéricas — o objetivo é sempre aprender algo que possas aplicar.', - }, - { type: 'p', text: 'Os temas variam consoante o que a comunidade precisa naquele momento:' }, - { - type: 'list', - items: [ - 'Discussões técnicas e de arquitetura', - 'Revisões de código em projetos reais', - 'Temas de carreira — entrevistas, negociação, progressão', - 'Sessões de Q&A com engenheiros experientes', - 'Walkthroughs de projetos dos próprios membros', - ], - }, - { - type: 'p', - text: 'A participação é sempre opcional. As sessões ficam gravadas para quem não conseguir estar presente.', - }, - ], - }, - { - q: 'Como sei se esta comunidade é para mim?', - blocks: [ - { - type: 'p', - text: 'A CommitPT não é para toda a gente — e isso é intencional. É para pessoas que gostam de aprender, de construir coisas, de fazer perguntas e de partilhar o que sabem.', - }, - { - type: 'p', - text: 'Se tens curiosidade genuína, se queres crescer como engenheiro a longo prazo, e se estás disposto a contribuir para o crescimento dos outros, vais sentir-te em casa.', - }, - { - type: 'p', - text: 'Se procuras motivação rápida ou conteúdo para consumir passivamente, provavelmente não é o sítio certo.', - }, - ], - }, - { - q: 'Qual é a diferença entre a comunidade gratuita e o Commit+?', - blocks: [ - { - type: 'p', - text: 'A comunidade gratuita no Discord **vai continuar a existir sempre**. Podes entrar, fazer perguntas, conhecer pessoas e participar nas discussões sem pagar nada.', - }, - { type: 'p', text: 'O Commit+ adiciona uma camada extra para quem quer ir mais fundo:' }, - { - type: 'list', - items: [ - 'Workshops práticos e calls ao vivo exclusivas', - 'Acesso a recursos e materiais premium', - 'Participação em projetos internos da comunidade', - 'Interação mais próxima com a equipa e membros seniores', - 'Mais oportunidades de aprender com outros membros', - 'Acesso antecipado à plataforma que estamos a construir', - ], - }, - ], - }, - { - q: 'Porque é que o Commit+ é pago?', - blocks: [ - { - type: 'p', - text: 'O objetivo não é monetizar o acesso à comunidade. O objetivo é **tornar o projeto sustentável** para que possa continuar a crescer.', - }, - { - type: 'p', - text: 'Organizar workshops com qualidade, manter recursos atualizados, desenvolver a plataforma e investir em iniciativas para a comunidade tem custos reais. O Commit+ é o que torna isso possível.', - }, - { - type: 'p', - text: 'Nenhum membro paga para ter acesso a uma comunidade — paga para ajudar a construir algo que beneficia todos, incluindo ele próprio.', - }, - ], - }, - { - q: 'Posso cancelar quando quiser?', - blocks: [ - { type: 'p', text: 'Sim. Sem contratos, sem compromissos a longo prazo.' }, - { - type: 'p', - text: 'O Commit+ funciona como uma **subscrição mensal simples**. Podes cancelar a qualquer momento, diretamente na plataforma, sem precisares de falar com ninguém.', - }, - ], - }, - { - q: 'E se não gostar?', - blocks: [ - { - type: 'p', - text: 'Podes experimentar e sair quando quiseres. Não há pressão nem complicações.', - }, - { - type: 'p', - text: 'Se entras, participas durante um mês e decides que não é para ti, cancelas e pronto. Não te pedimos justificações nem tentamos convencer-te a ficar.', - }, - { - type: 'p', - text: 'O que esperamos é que proves a ti próprio que vale a pena — e que fiques porque sentes valor, não porque te esqueceste de cancelar.', - }, - ], - }, -] - // ── Helpers ─────────────────────────────────────────────────────────────────── function renderInline(text: string): React.ReactNode { @@ -210,23 +54,11 @@ function renderBlocks(blocks: Block[]) { ) } -function blocksToPlainText(blocks: Block[]): string { - return blocks - .map((block) => - block.type === 'list' - ? block.items.map((item) => `- ${item.replace(/\*\*/g, '')}`).join('\n') - : block.text.replace(/\*\*/g, '') - ) - .join('\n\n') -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export default function FAQ() { - const faqSchema = { +function buildSchema(items: FaqItem[]) { + return { '@context': 'https://schema.org', '@type': 'FAQPage', - mainEntity: faqs.map((faq) => ({ + mainEntity: items.map((faq) => ({ '@type': 'Question', name: faq.q, acceptedAnswer: { @@ -235,8 +67,18 @@ export default function FAQ() { }, })), } +} + +// ── Component ───────────────────────────────────────────────────────────────── - const items: FAQAccordionItem[] = faqs.map((faq) => ({ +export default function FAQ({ + eyebrow, + heading, + description, + items, + withSchema = true, +}: FaqSectionProps) { + const accordionItems: FAQAccordionItem[] = items.map((faq) => ({ question: faq.q, answer:
{renderBlocks(faq.blocks)}
, })) @@ -244,28 +86,30 @@ export default function FAQ() { return (
{/* Schema Markup */} -