This is the codebase for Green Tractor. It is maintained by Max Davish. We have no official documentation system, so anything worth knowing is written down in this README.
Here is a list of the main technologies we use: Open Source
- Prisma for ORM
- NextJS for main application
- TailwindCSS for CSS
- Shadcn for UI components Paid
- Vercel for hosting
- Vercel Postgres (Neon) for database
- Pusher for realtime events
- Inngest for queues and background jobs
- Stripe for payments
- Resend for email
- Segment for analytics
- Mapbox for geosearch, maps, etc.
- Algolia for search and recommendations Coming Soon
- Algolia for search
- Open AI for AI stuff
- Vercel Blob + Cloudflare for image hosting
For the most part we use Prisma pretty normally, but there are some cases where we want to always trigger side effects when a database record is created or updated. For example:
- Send an update to Pusher every time a message, offer, or offer update is created
- Queue up an email to be sent in Inngest every time an offer or offer update is created
- Update the status of a listing to
PAID
every time an offer update with statusPAYMENT
happens
All of this logic lives in the PrismaSuperClient
, which exposes some new methods for creating certain objects. These methods work just like regular Prisma methods (same data
argument) except that they trigger these side effects for you and also don't have an include
argument, because they need to include certain other data themselves in order to trigger the right side effects.
Make sure to always use these methods in place of the raw Prisma methods, so use...
prisma.createOfferUpdate
notprisma.offerUpdate.create
prisma.createOffer
notprisma.offer.create
prisma.createMessage
notprisma.message.create
We use Pusher to listen to various events like new messages, new offers, etc.
There are TWO pusher channels that we care about:
- One Way Channel: (
to-${userId}
) This channel is how users get notifications from any of the conversations they're having with any other users. The logic for these notifications lives in the<NotificationsProvider/>
component, which shows a toast when new messages arrive in the one way channel. Because theNotificationsProvider
wraps the entire app, the one way channel is basically always subscribed to. - Two Way Channel: (
from-${fromUserId}-to-${toUserId}
) This channel is for listening a specific conversation beteen two users. We listen to this channel only when the user is on the/dashboard/conversations/{otherUserId}
page. The logic lives in the<ConversationPanel/>
component. In that component, we subscribe two two two-way channels - the "outgoing" and the "incoming" channel. That way, the UI updates every time that either user sends a message, or makes an offer, or updates an offer. Unlike the one way channel, we only subscribe to the two way channel when the user is on the conversation page.
In the pusher.ts
file, we create superclasses for PusherClient
and PusherServer
that add extra methods with added type safety to ensure that we are sending the right data on the server and reading the right data on the client.
From the pusherServer
, you should always use typedTrigger
to trigger events, and from pusherServer
you should use subscribeAndBind
and unsubscribeAndBind
. These methods will give you nice type safety!
We use Stripe for handling payments. Specifically we use Stripe Connect which is their product for marketplaces like ours. This guide basically explains the architecture we use.
Here are the main points you should know:
- In order to sell stuff on Green Tractor , a user first has to set up a "Connected Account" with us. When they do this, they enter some information with Stripe, and Stripe handles all the tricky KYC stuff that we don't want to handle. You can find the logic for this in
setupStripe.ts
. - If a user wants to buy something on Green Tractor, they don't need a connected account. When a user goes to buy something on Green Tractor, we simply use Stripe Checkout. With Stripe Checkout, Stripe hosts and handles the checkout page. You can find the logic for this in
createCheckoutSession.ts
. Some day, we might use Stripe Elements to host our own checkout page, but for now Stripe Checkout is easiest. - Once a user has purchased something, we pay the seller out using the logic in
<INSERT FILE HERE>
. (This part isn't quite done yet.)
During local development with Stripe, you should use the CLI to receive webhook events. You can find more information about this here.
Run this command:
stripe listen --forward-to localhost:3000/api/payments/stripe-webhook
This will point Stripe events to the API route at app/api/payments/stripe-webhook/route.ts
. This endpoint is in charge of handling Stripe events, such as when a payment succeeds.
You can also simulate fake events by using commands like...
stripe trigger payment_intent.succeeded
We use Inngest for stuff that would otherwise require queues, background jobs, cron jobs, etc. For example, we use Inngest to...
- Pay out sellers one week after a transaction closes, as long as the item wasn't returned/disputed
- Send a reminder email to a user if they haven't responded to an offer within a day
- Send users periodic weekly emails about listings they might be interested in
To use Inngest in local development, run this:
npx inngest-cli@latest dev
We use Resend and React Email to send emails to users.
Under the src/components/email
you'll find the actual email templates that we use to send emails. To develop these
templates locally, you can run npm run dev:email
which will start the React Email dev server (which, frankly, is a little buggy).
Importantly, you can't just use any old React component from this project in developing your emails. You have to use React Email's special components such as the Tailwind component or the Image component. These components ensure that things appear roughly consistently across email clients.
This also unfortunately means that we have to recreate some of our utilities like the cn
function and the FormattedDate
function. Overall you should think of the email templates as a separate project from the rest of the codebase.
The emails themselves are sent by Inngest - most of the code for that lives in src/lib/inngest/index.ts
.
We use Cloudinary for uploading and serving photos. This is very tricky, and there are a few things you need to know to understand how this is handled.
First, because we use NextJS server actions, you can't pass files directly to the actions. You can only pass plain objects. Additionally, because we use a Zod Resolver in react-hook-form, we can't pass a file to the resolver either. So, we have to upload the file to Cloudinary before we send it to the server.
So the recommended approach, according to this Github Issue, is to generate the Cloudinary URL on the client, and then pass that URL to the server. The server can then use that URL to download the file from Cloudinary and save it to the database.
We can do this using client side uploading with cloudinary. More specifically, we use the upload endpoint as opposed to their upload widget, because I think the widget is ugly.
We use next-cloudinary to serve the images. Responsize sizing is really tricky, but this is a great explainer on how to do it.
We ingest records to Algolia once per day on a cron job in Inngest, as well as upon creation of the listing in the creatListing
method of the Prisma client. The <SearchBar/>
component makes it easy to search for records in Algolia.