Project Overview
This section introduces Impact Blog, outlines its core features, and summarizes the tech stack. Frontend developers and contributors will learn what the project does and why it matters.
What is Impact Blog?
Impact Blog is a content-driven platform that connects mission-focused organizations with supporters. It combines editorial content, an online shop, and donation flows for an integrated giving experience.
Main Features
- Campaign Articles
Publish and browse long-form articles to showcase impact stories. - Magazine Shop
Sell print and digital editions using a Stripe-powered checkout. - Supporter Flows
Guide users through one-time and recurring donations, complete with confirmation and receipts.
Why It Matters
Impact Blog centralizes storytelling and fundraising in a single React application. It empowers nonprofit organizations to engage audiences, drive donations, and manage content without maintaining multiple platforms.
Technology Stack
- Frontend
• React 18 + TypeScript
• Vite for fast dev server and builds
• Tailwind CSS for utility-first styling - API Layer
• Axios for HTTP requests to the backend - Payments
• Stripe Checkout and Elements for secure transactions - Tooling
• ESLint with TypeScript-aware rules and React plugins
• Prettier for code formatting
Example: Key NPM Scripts
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
Who Should Read This
- Frontend developers joining the Impact Blog codebase
- Contributors adding features, fixing bugs, or improving styling
- UX engineers integrating new payment flows or customizing layouts
Getting Started
Follow these steps to clone, install, configure, and run the Impact Blog locally or in preview mode. You’ll also learn how to lint, format, and type-check the code. Vite’s React plugin provides Fast Refresh; TailwindCSS uses JIT for instant style updates.
1. Prerequisites
• Node.js ≥ 14.x
• npm ≥ 6.x
2. Clone & Install
git clone https://github.com/just-lend/impact-blog.git
cd impact-blog
npm install
3. Configure Environment Variables
Create a .env.local
file at project root. Vite exposes variables prefixed with VITE_
.
Example .env.local
:
VITE_API_URL=https://api.example.com
VITE_ANALYTICS_ID=UA-XXXXXXX-X
Restart the dev server after changing .env.local
.
4. Development
Run the local dev server with Fast Refresh and Tailwind JIT:
npm run dev
• Access at http://localhost:5173
• Tailwind rebuilds styles on file save
• React components hot-reload with state preservation
5. Preview Mode
Build and serve a production-like preview:
npm run build
npm run preview
• Serves optimized build on http://localhost:4173
• Matches production asset handling
6. Production Start
The Procfile defines the web process for platforms like Heroku:
web: npm start
npm start
invokes Vite’s preview server (equivalent to npm run preview
).
7. Linting & Formatting
• Run ESLint checks:
npm run lint
• Auto-fix and format with Prettier:
npm run format
ESLint config extends React and TypeScript rules for type-aware linting.
8. Type Checking
Project-wide type checking uses referenced configs in tsconfig.json
:
npm run type-check
This runs tsc --build
, validating both app and Node.js environment settings.
9. Configuration Reference
tsconfig.json
{
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
Update tsconfig.app.json
and tsconfig.node.json
to adjust environment-specific compiler options.
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from 'tailwindcss';
export default defineConfig({
plugins: [
react(), // Enables Fast Refresh
tailwindcss() // JIT styling
],
server: {
port: 5173
}
});
Customize Vite settings here for proxying APIs, aliasing, or adjusting build options.
Application Architecture
This section outlines the high-level structure of the React application in the just-lend/impact-blog repository. You’ll see how routing, shared utilities, and styling configuration tie together to provide a consistent developer experience.
Routing Configuration in App.tsx
Client-side routing uses react-router-dom
with scroll restoration and a consistent layout wrapper.
Imports and Setup
// src/App.tsx
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import ScrollToTop from "./components/ScrollToTop";
import Header from "./components/Header";
import Footer from "./components/Footer";
import LayoutContainer from "./components/LayoutContainer";
// Page components
import Magazine from "./pages/Magazine";
import ArticlePage from "./pages/Article";
import CategorisedArticles from "./pages/CategorisedArticles";
import About from "./pages/About";
import MerchProductView from "./pages/MerchProductView";
Defining Routes
const App: React.FC = () => (
<Router>
<ScrollToTop /> {/* Resets scroll on every navigation */}
<Header /> {/* Persistent top navigation */}
<LayoutContainer>
<Routes>
<Route path="/" element={<Magazine />} />
<Route path="/articles/:slug" element={<ArticlePage />} />
<Route path="/categories/:id" element={<CategorisedArticles />} />
<Route path="/about" element={<About />} />
<Route path="/merch/:slug" element={<MerchProductView />} />
{/* Add more routes here */}
</Routes>
</LayoutContainer>
<Footer /> {/* Persistent footer */}
</Router>
);
export default App;
Key Points
- ScrollToTop: Uses
useLocation
+window.scrollTo
to scroll to top on route change. - LayoutContainer: Applies global max-width, padding and centers content.
- Persistent UI:
<Header />
and<Footer />
live outside<Routes>
. - Dynamic Segments: URL params (
:slug
,:id
) are accessed viauseParams()
in page components.
Practical Usage
Add a New Page
- Create
src/pages/Contact.tsx
, useuseParams()
if needed. - Import in
App.tsx
and add<Route path="/contact" element={<Contact />} />
- Create
Nested Routes / Layouts
<Route path="/admin" element={<AdminLayout />}> <Route index element={<AdminDashboard />} /> <Route path="users" element={<UserList />} /> <Route path="users/:id" element={<UserDetail />} /> </Route>
AppContainer Wrapper
// src/AppContainer.tsx function AppContainer() { return ( <div className="min-h-screen w-full flex flex-col"> <App /> </div> ); } export default AppContainer;
Alerts Utility Functions
A thin wrapper around SweetAlert2 for consistent, promise-based dialogs.
Import
// src/components/alerts/index.ts
import Swal, { SweetAlertResult } from "sweetalert2";
export function showLoadingAlert(message?: string): Promise<SweetAlertResult> { /*...*/ }
export function showSuccessAlert(message: string): Promise<SweetAlertResult> { /*...*/ }
export function showErrorAlert(message: string): Promise<SweetAlertResult> { /*...*/ }
export function showInfoAlert(message: string): Promise<SweetAlertResult> { /*...*/ }
export function showConfirmDialogAlert(
heading: string,
description: string
): Promise<SweetAlertResult> { /*...*/ }
Functions Overview
showLoadingAlert(message?: string)
: Spinner + message (default “Please wait…”).showSuccessAlert(message: string)
: Check-icon success dialog.showErrorAlert(message: string)
: Cross-icon error dialog.showInfoAlert(message: string)
: Info-icon dialog.showConfirmDialogAlert(heading, description)
: “Yes/No” warning; resolves with.isConfirmed
.
Code Examples
import {
showLoadingAlert,
showSuccessAlert,
showErrorAlert,
showConfirmDialogAlert
} from "src/components/alerts";
async function submitForm(data: FormData) {
showLoadingAlert("Submitting…");
try {
await api.submit(data);
Swal.close(); // close loading
await showSuccessAlert("Submission successful!");
} catch {
Swal.close();
await showErrorAlert("Submission failed. Try again.");
}
}
async function handleDelete(id: string) {
const result = await showConfirmDialogAlert(
"Delete this item?",
"This action cannot be undone."
);
if (result.isConfirmed) {
await api.deleteItem(id);
await showSuccessAlert("Item deleted.");
}
}
Tips
- All functions return a
Promise
—await
to sequence dialogs. - Call
Swal.close()
to dismiss a loading alert before showing another. - To customize defaults, configure Swal mixins at app entry:
import Swal from "sweetalert2"; Swal.mixin({ customClass: { confirmButton: "btn btn-primary" } });
Tailwind Configuration: Custom Screens & Font Family
Extend Tailwind’s breakpoints and fonts to match your design system.
tailwind.config.js
// tailwind.config.js
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
screens: {
monitor: { max: "2560px" },
desktop: { max: "1279px" },
tablet: { max: "1199px" },
mobile: { max: "820px" }
},
extend: {
fontFamily: {
hanken: ["Hanken Grotesk", "sans-serif"]
}
}
},
plugins: []
};
Usage in JSX
<div className="p-8 monitor:p-6 desktop:p-4 mobile:p-2">
Responsive padding
</div>
<h1 className="font-hanken text-3xl mobile:text-2xl">
Welcome to Our Site
</h1>
Global Styles (src/index.css)
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: "Hanken Grotesk", sans-serif; /* syncs with tailwind.config.js */
max-width: 1820px;
margin: 0 auto;
display: grid;
place-items: center;
}
.article-page img {
width: 60%;
margin: 2rem auto;
}
.article-page .coverImage {
object-fit: cover;
width: 100%;
max-height: 40vh;
}
.article-page p {
margin: 2rem 0;
}
Practical Tips
- Use custom screen names (
monitor
,desktop
,tablet
,mobile
) instead of defaultsm/md/lg
. - Prefix utilities with breakpoints:
desktop:text-lg
,tablet:grid-cols-2
. - Apply
font-hanken
for all headings and body text to maintain consistency.
Data & API Integration
This section describes how the React front-end communicates with backend services via Axios, how to configure endpoints, and where to update them.
1. Configuring the API Base URL
All service modules use axios.defaults.baseURL
pointing to import.meta.env.VITE_API_URL
.
- Create a file
.env.local
at project root:VITE_API_URL=https://api.your-domain.com
- Ensure Vite loads it by restarting the dev server.
- In your service entry (e.g.
src/api/index.ts
), set:import axios from "axios"; axios.defaults.baseURL = import.meta.env.VITE_API_URL;
- To change endpoints for different environments, override
VITE_API_URL
in.env.production
or CI variables.
2. CampaignArticlesService
Client-side wrappers around /campaignArticles
endpoints. All methods return res.data
and may throw on HTTP errors.
// src/api/campaignArticleService.ts
import axios from "axios";
// Fetch latest articles (limit defaults to 10)
export async function getCampaignArticles(limit = 10) {
const res = await axios.get(`/campaignArticles/latest-campaign-articles`, {
params: { limit },
});
return res.data; // array of CampaignArticle
}
// Fetch a single article by slug
export async function getCampaignArticleBySlug(slug: string) {
const res = await axios.get(`/campaignArticles/${slug}`);
return res.data; // CampaignArticle
}
// Fetch articles by category filter
export async function getCampaignArticlesByCategory(
categoryId: number,
categoryName: string
) {
const res = await axios.get(`/campaignArticles/filter-by-category`, {
params: { categoryId, categoryName },
});
return res.data;
}
// Fetch by UNSDG number
export async function getCampaignArticlesByUNSDG(unsdg: number) {
const res = await axios.get(`/campaignArticles/filter-by-unsdg`, {
params: { unsdg },
});
return res.data;
}
// Fetch by local authority ID
export async function getCampaignArticlesByLocalAuthorityId(
localAuthorityId: number
) {
const res = await axios.get(`/campaignArticles/filter-by-local-authority`, {
params: { localAuthorityId },
});
return res.data;
}
Usage in a Component
import { useEffect, useState } from "react";
import {
getCampaignArticles,
getCampaignArticleBySlug,
} from "@/api/campaignArticleService";
import type { CampaignArticle } from "@/types";
export default function ArticleList() {
const [articles, setArticles] = useState<CampaignArticle[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getCampaignArticles()
.then(setArticles)
.catch(err => setError(err.response?.data?.message || err.message));
}, []);
if (error) return <p>Error: {error}</p>;
return (
<ul>
{articles.map(a => (
<li key={a.id}>
<a href={`/articles/${a.slug}`}>{a.title}</a>
</li>
))}
</ul>
);
}
3. CampaignCategoriesService
Retrieve campaign categories for filtering and navigation.
// src/api/campaignCategories.ts
import axios from "axios";
export async function getAllCampaignCategories() {
const res = await axios.get("/campaignCategories");
return res.data; // Category[]
}
Dropdown Example
import { useEffect, useState } from "react";
import { getAllCampaignCategories } from "@/api/campaignCategories";
import type { Category } from "@/types";
export function CategorySelector() {
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
getAllCampaignCategories().then(setCategories);
}, []);
return (
<select>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
);
}
4. StripeService
Helper functions for Stripe customer and checkout session endpoints.
// src/api/stripeService.ts
import axios from "axios";
export async function createStripeCustomer(email: string, name: string) {
const res = await axios.post("/stripe/customer", { email, name });
return res.data; // { customer: { id, email, ... } }
}
export async function getStripeCustomer(email: string) {
const res = await axios.get(`/stripe/customer?email=${email}`);
return res.data; // { customer: object|null }
}
export async function oneTimeCheckout(data: {
url: string;
prodName: string;
amount: number;
email: string;
}) {
const res = await axios.post("/stripe/checkout/one-time", data);
return res.data; // { id: string; url: string; }
}
export async function monthlyCheckout(data: {
url: string;
prodName: string;
amount: number;
email: string;
}) {
const res = await axios.post("/stripe/checkout/subscription", data);
return res.data;
}
Redirect Example
import { oneTimeCheckout } from "@/api/stripeService";
async function donate() {
const session = await oneTimeCheckout({
url: "https://app.com/thanks",
prodName: "Impact Donation",
amount: 500,
email: "user@example.com",
});
window.location.href = session.url;
}
5. Type Definitions
Import all interfaces from src/types
to enforce type safety in components and services.
CampaignArticle (excerpt)
export interface CampaignArticle {
id: number;
title: string;
slug: string;
preview: string;
image_urls: string[];
categories: string[];
sdgs: number[];
localAuthorityId: number;
}
Category
export interface Category {
id: number;
name: string;
}
StripeSession
export interface StripeSession {
id: string;
url: string;
}
Practical Tips
- Wrap all API calls in
try/catch
to handle errors and inspecterr.response
. - For custom limits or filters, extend service methods with additional parameters.
- To point at a mock server or alternate host, update
VITE_API_URL
without touching code. - Leverage strict TS settings and runtime validators (e.g. Zod) before casting API responses.
Payment & Donation Workflows
Provides an end-to-end walkthrough of the donation and subscription flows: UI components, Stripe service integration, checkout session creation, and referral tracking. Maintainers can safely update amounts, plans, and UI copy.
1. UI Pages
1.1 Support.tsx (ImpactSOS Zines)
Key features:
- Supporter type radio buttons (
monthly
vs.one-time
) - Amount selection (preset buttons + custom input)
- Personal details: name, email
- Local authority dropdown (fetched from API)
- Referral code capture via URL query
// src/pages/Support.tsx
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import { supporterTypes } from '../components/constants/supporterTypes'
import * as stripeService from '../api/stripeService'
export default function Support() {
const router = useRouter()
const { ref: referral } = router.query as { ref?: string }
const [supporterType, setSupporterType] = useState<string>(supporterTypes.OneTime)
const [amount, setAmount] = useState<number>(10)
const [name, setName] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [localAuthorities, setLocalAuthorities] = useState<string[]>([])
const [localAuthority, setLocalAuthority] = useState<string>('')
// Fetch local authorities on mount
useEffect(() => {
fetch('/api/local-authorities')
.then(res => res.json())
.then((data: string[]) => setLocalAuthorities(data))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// 1. Create or retrieve Stripe customer
const customer = await stripeService.createStripeCustomer({
email,
metadata: { name, localAuthority, referral: referral || '' }
})
// 2. Create Checkout Session
const common = {
customerId: customer.id,
successUrl: `${window.location.origin}/thank-you`,
cancelUrl: window.location.href,
supporterType,
referral: referral || ''
}
let sessionUrl: string
if (supporterType === supporterTypes.OneTime) {
const session = await stripeService.createOneTimeCheckoutSession({
...common,
amount: amount * 100 // convert £ to pence
})
sessionUrl = session.url
} else {
const session = await stripeService.createSubscriptionCheckoutSession({
...common,
priceId: process.env.NEXT_PUBLIC_MONTHLY_PRICE_ID!
})
sessionUrl = session.url
}
// 3. Redirect to Stripe Checkout
router.push(sessionUrl)
}
return (
<form onSubmit={handleSubmit}>
{/* Supporter Type */}
<fieldset>
<label>
<input
type="radio"
name="type"
value={supporterTypes.OneTime}
checked={supporterType === supporterTypes.OneTime}
onChange={() => setSupporterType(supporterTypes.OneTime)}
/> One-time
</label>
<label>
<input
type="radio"
name="type"
value={supporterTypes.Monthly}
checked={supporterType === supporterTypes.Monthly}
onChange={() => setSupporterType(supporterTypes.Monthly)}
/> Monthly
</label>
</fieldset>
{/* Amount Selector (buttons or input) */}
<div>
{[5, 10, 20].map(val => (
<button
type="button"
key={val}
onClick={() => setAmount(val)}
className={amount === val ? 'active' : ''}
>
£{val}
</button>
))}
<input
type="number"
min={1}
value={amount}
onChange={e => setAmount(Number(e.target.value))}
/>
</div>
{/* Personal Details */}
<input
type="text"
placeholder="Full name"
value={name}
onChange={e => setName(e.target.value)}
required
/>
<input
type="email"
placeholder="Email address"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
{/* Local Authority */}
<select
value={localAuthority}
onChange={e => setLocalAuthority(e.target.value)}
required
>
<option value="">Select local authority</option>
{localAuthorities.map(la => (
<option key={la} value={la}>{la}</option>
))}
</select>
<button type="submit">Continue to Payment</button>
</form>
)
}
1.2 Support copy.tsx (Legacy Page)
Monthly slider with optional custom input over £10.
// src/pages/Support copy.tsx
import { useState } from 'react'
import Slider from '@material-ui/core/Slider'
export default function LegacySupport() {
const [monthly, setMonthly] = useState<number>(10)
const [custom, setCustom] = useState<string>('')
const displayAmount = monthly > 10 && custom
? Number(custom)
: monthly
return (
<div>
<h2>Support us monthly</h2>
<Slider
value={monthly}
min={1}
max={50}
step={1}
onChange={(_, val) => setMonthly(val as number)}
/>
{monthly > 10 && (
<input
type="number"
placeholder="Custom amount"
value={custom}
onChange={e => setCustom(e.target.value)}
/>
)}
<p>You're pledging £{displayAmount} per month</p>
</div>
)
}
2. Stripe Service Integration
All Stripe calls live in src/api/stripeService.ts
. Functions return Stripe-typed responses and session URLs.
// src/api/stripeService.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2020-08-27' })
export interface SessionInput {
customerId: string
successUrl: string
cancelUrl: string
supporterType: string
referral?: string
}
export async function createStripeCustomer(params: {
email: string
metadata?: Record<string, string>
}): Promise<Stripe.Customer> {
return stripe.customers.create({ email: params.email, metadata: params.metadata })
}
export async function createOneTimeCheckoutSession(input: SessionInput & { amount: number }) {
return stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer: input.customerId,
line_items: [{ price_data: {
currency: 'gbp',
product_data: { name: 'One-Time Donation' },
unit_amount: input.amount
}, quantity: 1 }],
metadata: { supporterType: input.supporterType, referral: input.referral || '' },
success_url: input.successUrl,
cancel_url: input.cancelUrl
})
}
export async function createSubscriptionCheckoutSession(input: SessionInput & { priceId: string }) {
return stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'subscription',
customer: input.customerId,
line_items: [{ price: input.priceId, quantity: 1 }],
metadata: { supporterType: input.supporterType, referral: input.referral || '' },
success_url: input.successUrl,
cancel_url: input.cancelUrl
})
}
export async function retrieveStripeCustomer(customerId: string): Promise<Stripe.Customer> {
return stripe.customers.retrieve(customerId) as Promise<Stripe.Customer>
}
3. Referral Tracking
- Reads
ref
fromrouter.query
. - Stores in Stripe Customer metadata and Checkout Session metadata.
- To change the parameter key, update the destructuring in
Support.tsx
and metadata mapping.
4. Updating Pricing & Plans
- Monthly plan price IDs live in environment:
NEXT_PUBLIC_MONTHLY_PRICE_ID
. - To add tiers or change amounts:
- Adjust preset buttons or slider
min
/max
/step
in the UI. - Update env vars and redeploy to reflect new Stripe price IDs.
- Adjust preset buttons or slider
- Ensure
supporterTypes
constants (monthly
,one-time
) remain in sync with metadata logic.
Deployment & Environment Configuration
This section covers deploying just-lend/impact-blog to Heroku or any static host, configuring required environment variables, setting up CI type-checking, and optimizing images to keep the repo lean.
1. Environment Variables
Create a .env
file at the project root. Vite exposes vars prefixed with VITE_
via import.meta.env
.
VITE_API_BASE_URL=https://api.yourdomain.com
VITE_STRIPE_PUBLIC_KEY=pk_live_XXXXXXXXXXXXXXXXXXXX
# Only if you handle Stripe server-side (e.g. Netlify Functions):
STRIPE_SECRET_KEY=sk_live_XXXXXXXXXXXXXXXXXXXX
Access in code:
// src/api/client.ts
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export async function fetchPosts() {
const res = await fetch(`${API_BASE}/posts`);
return res.json();
}
2. Heroku Deployment
Add a Procfile:
web: npm start
Ensure
package.json
definesstart
andbuild
scripts:// package.json (scripts section) { "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "start": "npm run preview", "type-check": "tsc --project tsconfig.app.json && tsc --project tsconfig.node.json" } }
Commit and push to your Heroku remote:
git add Procfile .env git commit -m "Configure Heroku deployment" git push heroku main heroku config:set VITE_API_BASE_URL=https://api.yourdomain.com \ VITE_STRIPE_PUBLIC_KEY=pk_live_...
Heroku will run npm install
, npm run build
, then npm start
(serving dist/
).
3. Static Hosting
For Netlify, Vercel or any static host:
Build the app:
npm ci npm run build
Deploy the
dist/
folder.
- Netlify: drag-and-drop
dist/
or point a site at the repo and setnpm run build
as build command,dist
as publish directory. - Vercel: automatically detects Vite; use
npm run build
(Output Directory:dist
).
4. CI Type-Checking
Include type checks in CI to catch errors early.
Example GitHub Actions workflow .github/workflows/ci.yml
:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: npm ci
- name: Type-check
run: npm run type-check
- name: Build
run: npm run build
5. Image Optimization
Large images inflate repo size and build time. Use Vite plugins to compress during build:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwind from 'tailwindcss';
import viteImagemin from 'vite-plugin-imagemin';
export default defineConfig({
plugins: [
react(),
viteImagemin({
gifsicle: { optimizationLevel: 7 },
optipng: { optimizationLevel: 7 },
mozjpeg: { quality: 75 },
svgo: { plugins: [{ removeViewBox: false }] }
})
]
});
Alternatively:
- Pre-compress assets with tools like ImageOptim before committing.
- Serve images via a CDN (e.g., Cloudinary) and fetch on-the-fly.
This configuration streamlines deployment, enforces type safety, and keeps your build output optimized.