В настоящее время существует множество местных и глобальных проблем, и чаще всего кажется, что мы мало чем можем помочь. Но всегда есть что-то, что мы можем сделать!
Именно поэтому мы собираемся создать простое некоммерческое приложение, которое будет показывать потенциальных студентов и их истории, а также позволит всем желающим сделать пожертвование, используя Stripe. К тому времени, когда вы закончите этот урок, у вас будет базовый шаблон для сайта для студентов-доноров, который использует современные инструменты для быстрого создания и легкого масштабирования.
Инструменты, которые мы будем использовать
Для создания этого приложения мы будем использовать следующие технологии:
- Next.js — фреймворк React, позволяющий легко создать полнофункциональное приложение.
- Cosmic — безголовая CMS, которая дает нам возможность быстро управлять данными об учениках и пожертвованиях.
- Stripe — платежная система, которая позволит нам принимать пожертвования.
- Tailwind CSS — фреймворк стилей, позволяющий нам создавать отзывчивые макеты.
TL;DR
Установите шаблон приложения
Посмотрите живую демонстрацию
Проверьте код
Создание учетной записи Cosmic
Первое, что вам нужно будет создать, это бесплатный аккаунт Cosmic. Затем вам будет предложено создать новый проект. Убедитесь, что вы выбрали опцию «Начать с нуля». Проект будет называться non-profit-cms
, но вы можете назвать его как угодно. Вы можете оставить окружение bucket как «Production».
Далее нам нужно будет создать несколько типов объектов для наших доноров и студентов. На приборной панели Cosmic перейдите в раздел «Добавить тип объекта». Вы увидите следующее окно.
Убедитесь, что вы выбрали опцию «Множественный» объект. Вам нужно только заполнить «Единственное имя» Donor
, а остальные два поля сгенерируются автоматически. Далее нам нужно определить метаполя в «Модели содержимого».
У нас будет несколько различных полей для наших доноров: имя студента, сумма пожертвования, идентификатор сессии из Stripe, и, по желанию, имя донора и сообщение. После завершения работы у вас должны быть следующие метаполя.
Мы будем добавлять новые объекты доноров при каждом пожертвовании через Stripe, а затем сможем показывать пожертвования для каждого студента, когда начнем создавать приложение Next. Перед этим давайте закончим с типами объектов, которые нам понадобятся, добавив еще один тип под названием Student
.
Вернитесь в панель Cosmic и создайте «Новый тип объекта». Он также будет иметь тип «Multiple», и на этот раз «Singular Name» будет Student
. И снова нам нужно создать несколько метаполей для этого типа объекта. Поэтому прокрутите вниз до раздела «Модель содержимого» и добавьте эти метаполя: имя студента, специальность, университет, его историю и снимок головы. Вот как должны выглядеть все метаполя, когда вы закончите.
Теперь, когда вы получите данные о своих студентах и донорах, вы должны увидеть что-то похожее на это для студентов на вашей приборной панели.
И что-то похожее на это для доноров на вашей приборной панели.
Это все, что нам нужно, чтобы все настроить в Cosmic.
Получение некоторых значений для приложения Next
Теперь, когда Cosmic настроен так, как нам нужно, давайте получим несколько переменных окружения, которые понадобятся для приложения Next, которое мы собираемся создать. Зайдите в панель управления Cosmic и перейдите в Bucket > Settings > API Access
. Это даст вам возможность доступа, чтения и записи в ваш проект Cosmic. Мы будем работать со студентами и донорами, чтобы иметь возможность вести хороший учет того, кому отправлять соответствующие обновления для студентов.
Прежде чем мы создадим проект Next, есть еще одна служба, которую нам нужно правильно настроить. Нам нужен Stripe, чтобы мы могли принимать пожертвования.
Настройка учетной записи Stripe
Вам нужно перейти на сайт Stripe, чтобы создать бесплатную учетную запись. Главное, в чем вам нужно будет убедиться, это то, что ваша приборная панель оставлена в тестовом режиме и что вы добавили «Публичное название бизнеса» в Settings > Account Details
.
Теперь, когда ваша приборная панель настроена, вы можете получить две последние переменные окружения, которые понадобятся нам для приложения. Перейдите в раздел [Developers > API keys] (https://dashboard.stripe.com/test/apikeys)
и получите Publishable key
и Secret key
.
С этими значениями мы готовы к созданию приложения Next.
Настройка приложения Next.js
К счастью для нас, существует команда yarn
для создания нового приложения Next с готовыми конфигурациями. Таким образом, мы можем просто перейти к написанию кода. Чтобы создать этот проект, выполните в терминале следующую команду:
$ yarn create next-app --typescript
Затем мы можем добавить пакеты, с которыми будем работать, выполнив следующую команду:
$ yarn add cosmicjs tailwindcss stripe postcss @heroicons/react
Осталась последняя часть настройки, которую нужно сделать, прежде чем мы сможем погрузиться в код.
Добавление файла .env
Помните те значения, которые мы взяли из приборной панели Cosmic и приборной панели Stripe? Мы собираемся добавить их в проект в файл .env
. В корне проекта добавьте новый файл .env
. Внутри этого файла добавьте следующие значения:
# .env
READ_KEY=your_cosmic_read_key
WRITE_KEY=your_cosmic_write_key
BUCKET_SLUG=your_cosmic_bucket_slug
STRIPE_SECRET_KEY=your_stripe_secret_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
Когда все эти значения наконец-то установлены, мы можем приступить к самой интересной части создания приложения.
Настройка Tailwind CSS
Для того чтобы воспользоваться преимуществами установленного нами пакета Tailwind CSS, необходимо добавить несколько настроек. В корне вашего проекта должен быть файл tailwind.config.js
. Откройте этот файл и замените существующий код на следующий.
// tailwind.config.js
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
},
fontFamily: {
"sans": ["Helvetica", "Arial", "sans-serif"],
}
},
plugins: [],
}
Теперь загляните в папку styles
и вы должны увидеть файл global.css
. Именно так мы включим TailwindCSS в наш проект. Откройте этот файл и замените существующий код на следующий.
// global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
Это все, что нам нужно для работы наших стилей. Конечно, вы можете написать CSS самостоятельно, но иногда лучше воспользоваться уже существующим пакетом стилей.
Пара полезных компонентов
Теперь, когда мы можем создать стиль приложения, давайте добавим несколько компонентов, которые помогут связать страницы воедино. Мы добавим навигационную панель, чтобы постоянно возвращаться на главную страницу, и нижний колонтитул для брендинга, чтобы всегда показывать название вашей организации. В корне проекта добавьте новую папку components
.
Мы начнем с создания навигации, поэтому внутри папки components
добавьте новый файл Navigation.tsx
. Он будет отображать ссылку домой. Добавьте следующий код для создания этого компонента.
// Navigation.tsx
import Link from 'next/link'
import { HomeIcon } from '@heroicons/react/solid'
export default function Navigation() {
return (
<header className="p-4 border-b-2">
<Link passHref href={'/'}>
<div className="flex hover:cursor-pointer gap-2">
<HomeIcon className="h-6 w-6 text-blue-300" />
<div>Home</div>
</div>
</Link>
</header>
)
}
Последний маленький компонент, который нам нужно добавить, это нижний колонтитул. В папку components
добавьте новый файл Footer.tsx
. Он будет отображать текст и изображение иконки внизу каждой страницы. В этот новый файл добавьте следующий код.
// Footer.tsx
export default function Footer() {
return (
<footer className="p-4 border-t-2">
<a
href="https://www.cosmicjs.com?ref=non-profit-cms"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex gap-2">
<div>Powered by</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Cosmic logo"
src="https://cdn.cosmicjs.com/049dabb0-8e19-11ea-81c6-b3a804bfff46-cosmic-dark.png"
width="100"
height="100"
/>
</div>
</a>
</footer>
)
}
Это только два компонента, которые нам нужно было сделать. Теперь вам нужно обновить файл _app.tsx
, чтобы включить в него компонент Footer
. Таким образом, он будет отображаться на каждой странице приложения. Поэтому откройте этот файл и обновите существующий компонент, чтобы он соответствовал этому:
// _app.tsx
...
import Footer from '../components/Footer'
function MyApp({ Component, pageProps }: AppProps) {
return (
<div className="flex flex-col h-screen justify-between">
<Component {...pageProps} />
<Footer />
</div>
)
}
...
Обратите внимание, что здесь есть новый оператор импорта и что все приложение теперь обернуто в стилизованный div
, который также содержит этот элемент колонтитула. Мы добавляем элемент Navigation
только на отдельные страницы студентов, к чему мы перейдем позже.
Отображение всех студентов
Мы можем начать работу над приложением Next, чтобы отображать всех студентов всем, кто заходит на сайт. Мы начнем с обновления существующего файла index.tsx
, чтобы импортировать и использовать Cosmic для получения данных об учениках. Поэтому добавьте следующий код прямо под существующими импортами в файле.
// index.tsx
...
import Cosmic from 'cosmicjs'
const api = Cosmic()
const bucket = api.bucket({
slug: process.env.BUCKET_SLUG,
read_key: process.env.READ_KEY,
})
...
Затем нам нужно добавить функцию getStaticProps
для получения данных о студентах из Cosmic:
// index.tsx
...
export async function getStaticProps() {
const query = {
type: 'students',
}
const studentsReq = await bucket.getObjects({ query })
const students: Student[] = studentsReq.objects
return {
props: {
students,
},
}
}
...
Эта функция запускается только во время сборки страницы, поэтому вы не будете делать запрос каждый раз. Внутри этой функции мы определяем query
, который мы отправим в запросе Cosmic. Затем мы делаем запрос к bucket
, который мы определили ранее, и получаем все возвращенные объекты студентов. Наконец, мы отправляем массив students
в props компонента страницы.
Теперь, когда у нас есть эти данные, мы можем вывести некоторые элементы на главную страницу. Вы можете удалить весь текущий код, который находится внутри компонента Home
, и заменить его следующим:
// index.tsx
...
const Home: NextPage = ({ students }) => {
if (!students) {
return <div>Loading our incredible students...</div>
}
return (
<div>
<Head>
<title>Student Raiser</title>
<meta
name="description"
content="A website dedicated to helping students receive the funding they need for college"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1 className="px-11 pt-11 text-2xl">Students in your area</h1>
<div className="flex flex-wrap gap-4 p-11">
{students.map((student: Student) => (
<div
className="hover:cursor-pointer w-64"
key={student.metadata.name}
>
<Link
passHref
href={`/student/${encodeURIComponent(student.slug)}`}
>
<div
key={student.slug}
className="border-2 rounded max-w-sm rounded overflow-hidden shadow-lg"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${student.metadata.student_headshot.imgix_url}?w=400`}
alt={student.metadata.name}
className="w-full"
style={{ backgroundPosition: 'cover' }}
/>
<div className="p-4">
<div className="text-amber-800 p-1">
{student.metadata.name}
</div>
<div className="border-b-2 p-1">
{student.metadata.major}
</div>
<div className="p-1">{student.metadata.university}</div>
</div>
</div>
</Link>
</div>
))}
</div>
</main>
</div>
)
}
...
Это отображение на массив students
для создания элемента для выделения каждого студента. На каждый из этих элементов можно нажать, чтобы узнать больше о конкретном студенте, и именно на этой странице мы сейчас и будем работать. Мы вернемся и добавим больше стилей, но если вы запустите приложение с помощью yarn dev
, вы должны увидеть что-то похожее на это.
Создание страницы для отдельных студентов
Теперь мы воспользуемся встроенной в Next динамической маршрутизацией для создания страниц для каждого ученика. Добавьте новую папку в каталог pages
под названием student
. Внутри этой папки добавьте новый файл [имя].tsx
.
Давайте начнем с добавления импортов, которые понадобятся для работы этой страницы. В верхней части файла [name].tsx
добавьте следующие строки.
// [name].tsx
import { useEffect, useState } from 'react'
import Cosmic from 'cosmicjs'
import { Donor, Student } from '../../types'
import Navigation from '../../components/Navigation'
import {
BadgeCheckIcon,
ExclamationIcon,
UserCircleIcon,
UserIcon,
} from '@heroicons/react/solid'
...
Пока не беспокойтесь о файле types
. Мы добавим его в ближайшее время. А пока давайте добавим скелет для компонента Student
под нашими импортами.
// [name].tsx
...
function Student({ student, donors }) {
return (
<>
<h2 className="container text-3xl py-8">{student.metadata.name}</h2>
</>
)
}
export default Student
Мы еще многое добавим в этот компонент, но сначала нам нужно получить данные student
и donors
. Мы будем использовать функцию getServerSideProps
для получения данных о конкретном студенте из Cosmic при каждом вызове этого маршрута. Все это не происходит в браузере, поэтому данные остаются в безопасности.
// [name].tsx
...
export async function getServerSideProps(context) {
const slug = context.params.name
const studentRes = await bucket.getObjects({
props: 'metadata,id',
query: {
slug: slug,
type: 'students',
},
})
const student: Student = studentRes.objects[0]
try {
const donorsRes = await bucket.getObjects({
props: 'metadata',
query: {
type: 'donors',
'metadata.student': slug,
},
})
let total
const donors: Donor[] = donorsRes ? donorsRes.objects : null
if (donors.length === 1) {
total = donors[0].metadata.amount
} else {
total = donors
.map((donor) => donor.metadata.amount)
.reduce((prev, curr) => prev + curr, 0)
}
return {
props: {
student,
donors,
total,
},
}
} catch {
return {
props: {
student,
donors: null,
total: 0,
},
}
}
}
Затем мы передадим эти данные в компонент, чтобы выделить конкретного студента для пользователей и потенциальных доноров. В компоненте Student
мы сделаем несколько вещей. Во-первых, мы проверим, была ли страница студента посещена через перенаправление со страницы проверки Stripe. Затем мы отобразим информацию о студенте, которую мы храним в Cosmic. Далее у нас будет форма для заполнения донорами, если они хотят сделать пожертвование для этого конкретного студента. И наконец, у нас будет список всех жертвователей для этого конкретного студента.
Итак, вы можете заменить набросок компонента Student
следующим полным кодом.
// [name].tsx
...
function Student({ student, donors, total }) {
const [query, setQuery] = useState<string>('')
useEffect(() => {
// Check to see if this is a redirect back from Checkout
const query = new URLSearchParams(window.location.search)
if (query.get('success')) {
setQuery('success')
console.log('Donation made! You will receive an email confirmation.')
}
if (query.get('canceled')) {
setQuery('canceled')
console.log(
'Donation canceled -- something weird happened but please try again.'
)
}
}, [])
return (
<div>
<Navigation />
{query === 'success' && (
<div
className="bg-green-100 rounded-lg py-5 px-6 mb-3 text-base text-green-700 inline-flex items-center w-full"
role="alert"
>
<BadgeCheckIcon className="w-4 h-4 mr-2 fill-current" />
Donation made! You will receive an email confirmation.
</div>
)}
{query === 'canceled' && (
<div
className="bg-yellow-100 rounded-lg py-5 px-6 mb-3 text-base text-yellow-700 inline-flex items-center w-full"
role="alert"
>
<ExclamationIcon className="w-4 h-4 mr-2 fill-current" />
Donation canceled -- something weird happened but please try again.
</div>
)}
<h2 className="container text-3xl py-8">{student.metadata.name}</h2>
<div className="container flex gap-4">
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${student.metadata.student_headshot.imgix_url}?w=800`}
alt={student.metadata.name}
style={{ backgroundPosition: 'cover' }}
/>
<div className="container border-y-2 my-4">
<p className="font-bold pt-4 px-2">
Major: {student.metadata.major}
</p>
<p className="font-bold border-b-2 pb-4 px-2">
University: {student.metadata.university}
</p>
<p className="py-4 px-2">{student.metadata.story}</p>
</div>
</div>
<div>
<p className="font-bold text-xl pb-4">Total raised: ${total}</p>
<form
action="/api/donation"
method="POST"
className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
>
<input name="student_id" type="hidden" value={student.id} />
<div className="mb-4">
<label
className="block text-gray-700 text-sm font-bold mb-2"
htmlFor="amount"
>
Donation Amount
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
name="amount"
type="number"
defaultValue={100}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Name
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
name="name"
type="text"
defaultValue="Anonymous"
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2">
Message
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
name="message"
type="text"
defaultValue="Good Luck!"
/>
</div>
<div>
<button
type="submit"
role="link"
className="hover:bg-lime-400 text-white font-bold py-2 px-4 border-b-12 border-lime-700 hover:border-lime-500 rounded-full text-lg bg-lime-500 w-64 mt-6 mx-8"
>
Make a Donation
</button>
</div>
</form>
<div className="flex flex-col gap-4 pt-4 w-full">
{donors ? (
donors.map((donor: Donor) => (
<div
key={donor.slug}
className="border-b-2 rounded p-4 w-64 flex gap-4"
>
<UserCircleIcon className="h-12 w-12 text-green-300" />
<div>
<p>{donor.metadata.name}</p>
<p>${donor.metadata.amount}</p>
</div>
</div>
))
) : (
<div className="border-2 rounded p-4 w-64 flex gap-4">
<UserCircleIcon className="h-12 w-12 text-green-300" />
<p>Be the first donor!</p>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4 p-11 w-full">
<h2 className="text-xl font-bold">Encouragement</h2>
{donors ? (
donors.map((donor: Donor) => (
<div
key={donor.slug}
className="flex flex-col border-b-2 rounded p-4 gap-4"
>
<div className="w-64 flex gap-4 w-full">
<UserIcon className="h-12 w-12 text-green-300" />
<div className="flex flex-col">
<p>{donor.metadata.name}</p>
<p>${donor.metadata.amount}</p>
</div>
</div>
<p>{donor.metadata.message}</p>
</div>
))
) : (
<div>You can do it!</div>
)}
</div>
</div>
)
}
...
Если вы запустите приложение сейчас с помощью yarn dev
, вы должны увидеть что-то похожее на это для одного из студентов.
Теперь, когда мы заполнили всю функциональность, давайте добавим файл types.ts
, чтобы не получить никаких ошибок TypeScript.
Добавление файла types
Наличие определенных типов для наших данных поможет нам узнать, когда API изменились, и мы не столкнемся с большим количеством непредвиденных ошибок в производстве. В корне вашего проекта создайте новый файл types.ts
и добавьте следующий код:
// types.ts
export interface Student {
metadata: {
name: string
student_headshot: {
url: string
imgix_url: string
}
major: string
university: string
story: string
}
slug: string
}
export interface Donor {
slug: string
metadata: {
name: string
amount: number
message: string
}
}
Это поможет нам определить данные, которые мы ожидаем использовать от наших вызовов API к Cosmic.
Добавление функциональности оформления заказа Stripe
Последнее, что нам нужно добавить, это API, который будет вызываться при отправке формы пожертвования, и для этого мы будем использовать Stripe. Если вы найдете в каталоге pages > api
вашего проекта, вы увидите файл под названием hello.ts
. Вы можете удалить этот файл-заполнитель и создать новый файл под названием donation.ts
.
Давайте откроем этот новый файл и следующие импорты.
// donation.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import Cosmic from 'cosmicjs'
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
Поскольку нам нужно обработать только один POST-запрос, наша функция-обработчик может быть относительно простой. Мы проведем быструю проверку, чтобы убедиться, что выполняется POST-запрос. Если будет сделан запрос другого типа, то мы выдадим ошибку.
После проверки запроса мы создадим оператор try-catch, который сначала проверит, можем ли мы установить соединение с нашим Cosmic bucket, чтобы добавить нового донора. После этого мы создадим сессию оформления заказа в Stripe, используя информацию о форме, переданную из front-end. Затем мы получаем сессию от Stripe, чтобы добавить их данные в Cosmic.
Наконец, мы создаем метаполе для добавления нового донора в нашу приборную панель Cosmic и используем метод addObject
, чтобы убедиться, что этот донор будет записан в правильный объект. Добавьте следующий код, чтобы выполнить всю эту работу.
// donation.ts
...
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
try {
const api = Cosmic()
const bucket = api.bucket({
slug: process.env.BUCKET_SLUG,
read_key: process.env.READ_KEY,
write_key: process.env.WRITE_KEY,
})
const { student_id, amount, name, message } = req.body
const student = (
await bucket.getObject({ id: student_id, props: 'id,title,slug' })
).object
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
line_items: [
{
amount: amount * 100, // Cents
currency: 'usd',
quantity: 1,
name: `Donation - ${student.title}`,
},
],
mode: 'payment',
success_url: `${req.headers.referer}/?success=true`,
cancel_url: `${req.headers.referer}/?canceled=true`,
})
const donorParams = {
title: name,
type: 'donors',
metafields: [
{
title: 'Name',
type: 'text',
value: name,
key: 'name',
},
{
title: 'Student',
type: 'text',
value: student.slug,
key: 'student',
},
{
title: 'Amount',
type: 'number',
value: Number(amount),
key: 'amount',
},
{
title: 'Message',
type: 'text',
value: message,
key: 'message',
},
{
title: 'Stripe Id',
type: 'text',
value: session.id,
key: 'stripe_id',
},
],
}
await bucket.addObject(donorParams)
res.redirect(303, session.url)
} catch (err) {
res.status(err.statusCode || 500).json(err.message)
}
} else {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}
Готовый код
Вы можете найти весь код для этого проекта в этом репозитории.
Развертывание на Vercel
Вы можете развернуть этот шаблон на Vercel, нажав здесь.
Заключение
Теперь у вас есть полностью интегрированный сайт для пожертвований, который вы можете настроить для любого типа некоммерческих организаций, занимающихся сбором средств и пожертвований. Не стесняйтесь клонировать его и изменять стили в соответствии с потребностями вашей организации.