diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2219d0d --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/aiabasd + +# NextAuth +NEXTAUTH_SECRET=changeme +NEXTAUTH_URL=http://localhost:3000 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= + +# App +NEXT_PUBLIC_APP_URL=http://localhost:3000 +SITE_BASE_URL=http://localhost:3000 + +# i18n +DEFAULT_LOCALE=ar + +# Strapi CMS +STRAPI_URL=http://localhost:1337 +STRAPI_API_TOKEN=your_strapi_readonly_token + +# Search (Algolia or Meilisearch) +NEXT_PUBLIC_ALGOLIA_APP_ID= +NEXT_PUBLIC_ALGOLIA_SEARCH_KEY= +ALGOLIA_ADMIN_KEY= +MEILISEARCH_HOST= +MEILISEARCH_API_KEY= + +# reCAPTCHA v3 +RECAPTCHA_SITE= +RECAPTCHA_SECRET= + +# Analytics +NEXT_PUBLIC_GA_ID= +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com + +# Storage (S3-compatible) +STORAGE_BUCKET= +STORAGE_ACCESS_KEY_ID= +STORAGE_SECRET_ACCESS_KEY= +STORAGE_ENDPOINT= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1f2109 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install pnpm + run: npm i -g pnpm + + - name: Install deps + run: pnpm i + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Unit tests + run: pnpm test + + - name: E2E tests (Playwright) + run: pnpm e2e \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f10d005 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# AIABASD / AIBA Investor Platform + +Trilingual (Arabic default, English and French mirrors) investor website for African International Business Alliance & Sustainable Development (AIABASD/AIBA). + +## Tech Stack + +- Next.js 15 (App Router, RSC) + TypeScript +- Tailwind CSS (RTL-aware) +- next-intl for i18n (ar, en, fr) +- Prisma + PostgreSQL +- NextAuth (gated investor portal) +- Meilisearch or Algolia (search index scaffolding) +- React Hook Form + Zod (forms) +- PDF generation via pdf-lib (LOI, AR/EN/FR) +- Analytics: GA4, PostHog +- Tests: Playwright (E2E), Vitest (unit) +- CI: GitHub Actions (typecheck, lint, unit, e2e) + +## AIO SEO + +Central module `lib/seo.ts` provides: +- Localized titles/descriptions (safe limits), canonical, hreflang (ar, en, fr, x-default→/en) +- Open Graph + Twitter tags with localized alt +- JSON-LD helpers for Organization, WebSite, BreadcrumbList, Offer, Article, Place +- Sitemaps: + - Next sitemap (app/sitemap.ts) + - Custom sitemap with `` alternates (app/sitemap.xml/route.ts) +- Robots: app/robots.txt + +## Getting Started + +1. Clone and install: +- pnpm i + +2. Environment variables: +- Copy .env.example to .env.local +- Set DATABASE_URL (Postgres) +- Set NEXTAUTH_SECRET and NEXTAUTH_URL +- Set NEXT_PUBLIC_APP_URL and SITE_BASE_URL +- Optional: MEILISEARCH_* or ALGOLIA_* keys, RECAPTCHA_* keys, analytics keys. + +3. Database and seed: +- pnpm prisma:generate +- pnpm prisma:migrate +- pnpm db:seed + +4. Run: +- pnpm dev +- Visit http://localhost:3000 (auto-redirects to /ar) + +5. Build and start: +- pnpm build && pnpm start + +6. Tests: +- pnpm test +- pnpm e2e + +## Routes + +- /ar (default), /en, /fr +- Home, Opportunities (list + detail), Countries, Investor Portal (intent) +- API: /api/opportunities, /api/investor/intent, /api/investor/intent/[id]/pdf +- SEO: /sitemap.xml, /robots.txt + +## Acceptance Criteria coverage (MVP) + +1) Languages: +- Arabic RTL default; EN/FR LTR; language toggle persists (URL-based). + +2) Opportunities: +- Server-side filters and text search (Prisma). Also /api/opportunities. +- Detail pages show localized fields and docs. + +3) Investor flow: +- Intent form posts to /api/investor/intent with rate limit + reCAPTCHA hook. +- Generates LOI PDF localized (AR/EN/FR) at /api/investor/intent/:id/pdf. + +4) Country Profiles: +- 3 demo profiles, each with ≥5 indicators and a map placeholder. + +5) SEO: +- Canonical + hreflang via lib/seo.ts generateMetadata. +- JSON-LD for Organization/WebSite on home; Breadcrumb + Offer on opportunity. +- Sitemaps and robots included. + +6) Security: +- CSP, clickjacking, content-type, permissions headers via middleware. +- Basic API rate limiting. +- reCAPTCHA v3 hook (requires secrets for production). + +7) Tests/CI: +- Playwright smoke tests for home and opportunities list. +- Vitest unit for i18n RTL utility. +- GitHub Actions workflow runs typecheck, lint, unit, e2e. + +## Deployment (Vercel) + +- Configure environment variables in Vercel project. +- Set SITE_BASE_URL to your production domain. +- Middleware handles locale redirects and portal gating. + +## Seed Content + +Seed script populates: +- 6 opportunities (energy, agriculture, healthcare, education, infrastructure, tourism) AR/EN/FR +- 3 partners +- 3 news posts AR/EN/FR +- 3 country profiles AR/EN/FR + +Arabic is the source of truth with English and French mirrors. \ No newline at end of file diff --git a/README_STRAPI.md b/README_STRAPI.md new file mode 100644 index 0000000..4c79cd2 --- /dev/null +++ b/README_STRAPI.md @@ -0,0 +1,45 @@ +# Strapi Content Types for AIABASD / AIBA + +This folder contains Strapi v4 content-type and component schemas to power the CMS for the investor platform. + +## Structure + +- content-types + - opportunity: AR/EN/FR, slug (uid), summaries, sector/country, ticket sizes, stage, SDGs, ESG score, KPIs, documents, hero image, SEO + - partner: name, type, country, logo, link, SEO + - country-profile: iso2, names AR/EN/FR, summary, indicators (component), SEO + - news-post: titles AR/EN/FR, slug (uid), rich bodies AR/EN/FR, published_at, SEO +- components/common + - indicator: key/value pairs + - kpi: label/value pairs + - seo: AIO SEO fields per locale, og_image, noindex/nofollow, schema_type + +## How to use + +1) Create a Strapi v4 project: +- npx create-strapi-app@latest cms --quickstart + +2) Copy these files into your Strapi project: +- Place content-types JSON under: cms/src/api/{type}/content-types/{type}/schema.json + - For example: cms/src/api/opportunity/content-types/opportunity/schema.json +- Place components JSON under: cms/src/components/common/{component}.json + +3) Rebuild Strapi: +- cd cms +- npm run develop +- Visit http://localhost:1337/admin +- Create an admin user +- Verify collection types and components exist + +4) Configure media: +- Set S3-compatible provider (optional) for uploads. + +5) Connect Next.js: +- Expose Read-only API tokens in Strapi Settings → API Tokens. +- In Next.js, set ENV for STRAPI_URL and STRAPI_API_TOKEN, then fetch via REST or GraphQL. + +## Notes + +- SEO fields align with the AIO SEO module in Next.js. +- Slug uses title_en as source to create a canonical cluster with hreflang alternates. +- Add relations if needed (e.g., linking opportunities to partners). \ No newline at end of file diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2b2a104 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useState } from 'react' +import { supabase } from '@/lib/supabase/client' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + const { error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) { + setError(error.message) + } else { + window.location.href = '/dashboard' + } + setLoading(false) + } + + return ( +
+

Welcome back

+

Sign in to your account

+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + /> +
+ {error &&

{error}

} + +
+ Don't have an account? Create one +
+
+
+ ) +} \ No newline at end of file diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..2708d6a --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { supabase } from '@/lib/supabase/client' +import { useState } from 'react' + +const schema = z.object({ + fullName: z.string().min(2, 'Enter your full name'), + email: z.string().email('Enter a valid email'), + password: z.string().min(8, 'Minimum 8 characters'), + userType: z.enum(['entrepreneur', 'investor', 'organization', 'service_provider']), +}) + +type FormValues = z.infer + +export default function RegisterPage() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + fullName: '', + email: '', + password: '', + userType: 'entrepreneur', + }, + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (values: FormValues) => { + setLoading(true) + setError(null) + const { data, error } = await supabase.auth.signUp({ + email: values.email, + password: values.password, + options: { + data: { + full_name: values.fullName, + user_type: values.userType, + }, + emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/verify` + }, + }) + if (error) { + setError(error.message) + } else { + window.location.href = '/verify' + } + setLoading(false) + } + + return ( +
+

Create your account

+

Join the Africa Growth Hub

+ +
+
+ + + {form.formState.errors.fullName && ( +

{form.formState.errors.fullName.message}

+ )} +
+
+ + + {form.formState.errors.email && ( +

{form.formState.errors.email.message}

+ )} +
+
+ + + {form.formState.errors.password && ( +

{form.formState.errors.password.message}

+ )} +
+
+ + + {form.formState.errors.userType && ( +

{form.formState.errors.userType.message}

+ )} +
+ {error &&

{error}

} + +
+ Already have an account? Sign in +
+
+
+ ) +} \ No newline at end of file diff --git a/app/(auth)/verify/page.tsx b/app/(auth)/verify/page.tsx new file mode 100644 index 0000000..2ca87e8 --- /dev/null +++ b/app/(auth)/verify/page.tsx @@ -0,0 +1,15 @@ +export default function VerifyPage() { + return ( +
+

Verify your email

+

+ We sent a verification link to your email. Please click the link to activate your account. +

+ +
+ ) +} \ No newline at end of file diff --git a/app/(public)/funding/page.tsx b/app/(public)/funding/page.tsx new file mode 100644 index 0000000..0a21977 --- /dev/null +++ b/app/(public)/funding/page.tsx @@ -0,0 +1,45 @@ +export default function FundingPage() { + const items = [ + { + slug: 'green-grants-2025', + title: 'Green Innovation Grants', + type: 'grant', + amount: 'Up to $50,000', + deadline: '2025-12-31', + }, + { + slug: 'impact-loans-east-africa', + title: 'Impact Loans - East Africa', + type: 'loan', + amount: '$10,000 - $250,000', + deadline: '2025-11-15', + }, + { + slug: 'seed-equity-africa', + title: 'Seed Equity Fund Africa', + type: 'equity_investment', + amount: '$100,000 - $1,000,000', + deadline: '2025-10-31', + }, + ] + + return ( +
+

Funding Opportunities

+

Discover grants, loans, and impact investments.

+ + +
+ ) +} \ No newline at end of file diff --git a/app/(public)/projects/[slug]/page.tsx b/app/(public)/projects/[slug]/page.tsx new file mode 100644 index 0000000..c82652c --- /dev/null +++ b/app/(public)/projects/[slug]/page.tsx @@ -0,0 +1,91 @@ +interface Params { + params: { slug: string } +} + +export default function ProjectDetailPage({ params }: Params) { + const { slug } = params + + // Placeholder data. Replace with Supabase fetch by slug. + const project = { + title: 'Solar Village Initiative', + description: + 'A community-led solar microgrid project delivering affordable, clean energy to rural households.', + country: 'Kenya', + city: 'Kisumu', + category: 'renewable_energy', + fundingGoal: 250000, + fundingRaised: 85000, + } + + const progress = Math.round((project.fundingRaised / project.fundingGoal) * 100) + + return ( +
+
+
+
+

{project.title}

+
+ {project.country} + {project.city} + + {project.category.replace('_', ' ')} + +
+ +
+

Overview

+

{project.description}

+
+ +
+

Impact

+
    +
  • 1000 households connected to clean energy
  • +
  • 30 local jobs created
  • +
  • SDG7: Affordable and Clean Energy
  • +
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/(public)/projects/page.tsx b/app/(public)/projects/page.tsx new file mode 100644 index 0000000..48e3343 --- /dev/null +++ b/app/(public)/projects/page.tsx @@ -0,0 +1,29 @@ +export default function ProjectsPage() { + const projects = [ + { slug: 'solar-village-kenya', title: 'Solar Village Initiative', country: 'Kenya', category: 'renewable_energy' }, + { slug: 'agri-supply-ghana', title: 'Agri Supply Chain', country: 'Ghana', category: 'agriculture' }, + { slug: 'healthtech-nigeria', title: 'Community HealthTech', country: 'Nigeria', category: 'healthcare' }, + ] + + return ( +
+
+

Projects

+ Create Project +
+ + + ) +} \ No newline at end of file diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx new file mode 100644 index 0000000..152194f --- /dev/null +++ b/app/[locale]/contact/page.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useState } from 'react' + +export default function ContactPage({ params }: { params: { locale: 'ar' | 'en' | 'fr' } }) { + const isAr = params.locale === 'ar' + const isFr = params.locale === 'fr' + const t = (ar: string, en: string, fr: string) => (isAr ? ar : isFr ? fr : en) + + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [category, setCategory] = useState('investor') + const [message, setMessage] = useState('') + const [loading, setLoading] = useState(false) + const [ok, setOk] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + const recaptchaToken = 'test' // TODO: integrate grecaptcha v3 site key + + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, category, message, recaptchaToken }), + }) + if (res.ok) { + setOk(true) + } else { + const data = await res.json().catch(() => ({})) + setError(data.error || 'failed') + } + setLoading(false) + } + + return ( +
+

{t('اتصل بنا', 'Contact Us', 'Contactez-nous')}

+
+
+ + setName(e.target.value)} required /> +
+
+ + setEmail(e.target.value)} required /> +
+
+ + +
+
+ +