In this chapter, we're going to go ahead and implement the checkout page, continuing from our previous chapter where we implemented the cart functionality. In order to do that, we're going to have to implement the checkout page, checkout layout, and the checkout procedures. As always, ensure that you're up-to-date on your master branch, which you can do by running git status. After that, go ahead and do fun run dev. Then, let's go ahead and let's go and load our app and let's visit a random store.
So I have John's product here. So I'm going to go ahead and visit John's store here. And make sure you add something to your cart. Right now clicking on this button redirects me to tenants John checkout but it's not found. We can quickly create that inside of app tenants tenants slug and go ahead and create a new checkout route group here, and then a checkout folder, and inside a page.tsx.
Div checkout page. So it's rendered here. Localhost 3000, tenants, slug, checkout. There we go. No more error.
Instead, it's a checkout page now. Now, let's go inside of the checkout route group and create the layout.tsx. And let's go ahead and prepare some components. Go inside of source, modules, tenants, UI, and copy the navbar. Now go inside of checkout, and let's go inside of the UI folder.
Inside of components. And let's go ahead and create our own navbar here. And my apologies, let's just copy the entire thing and paste it here from tenants. So now the navbar in the checkout module and in the tenants module is identical. Make sure that you are modifying the one in the new checkout UI components navbar here.
We can remove the skeleton because there's not going to be any loading here. We can remove the dynamic checkout button as there's not going to be any dynamic loading as well. We could remove the RPC and loading entirely, but leave the button, we are going to need it. Leave the slug prop here and modify this a little bit. So we're not going to have this, but we will have a paragraph here which will simply write the text Checkout.
And in here, we're going to add a button, continue shopping. This button will have a variant of elevated as child prop and inside a link. The link will have an href of generate tenant URL and pass in the slot. Make sure you have the link imported from here. And you can remove the image and I don't think we need use client explicitly for any reason here.
Great. Now let's go ahead and let's go inside of our newly created checkout route group in the tenants here. And in the layout, let's go ahead and do the layout. And what we're going to do here is we're going to copy the layout from our home in the tenant slug here. So it's easier.
Let's just copy the entire thing and paste it here. But we are going to simplify it. So we are keeping the params and the children, but there is no need for any prefetching, which means there is no need for the suspension, for the suspense, for the hydration boundary, for none of that. Now let's remove the unnecessary files. The footer will stay the same as for the tenants, but the navbar should be imported from the checkout.
So it should still accept the slug prop, because we are using it to generate the URL. Now when you visit the checkout page, you should see continue shopping, which redirects you back to that tenant's store. And you should no longer be getting any 404s. Great. Now let's go ahead and let's implement the checkout page.
And let me just see on mobile. Yeah, I want to leave the continue shopping here. I don't see it as a problem. I think it can stay here. Now let's implement the actual checkout page.
In order to do that, we have to go instead of checkout page.tsx. And what we're going to do is we're just going to return the checkout page view. My apologies, it's going to be called checkout view. And we're also going to have some interface here just to load the slug from the params. So turn the page into an asynchronous component, which has access to the params, and just await the params here.
And then pass in tenant slug, slug. Now let's implement the checkout view component. We're going to implement that inside of source modules checkout in the UI. Go ahead and create a new folder called views and inside checkout-view.tsx. Let's go ahead and export the constant checkout view and let's also pass in the checkout view props.
We can now extract the tenant slug from the checkout view props here. Let's go ahead and return a div, including the tenant slug so we can see the data drilling working. We can now import this from modules checkout UI views, checkout view. And you should just see a text John, which is basically the slug that we are on. Now let's go back inside of the checkout view.
Let's mark this as a used client. What we have to do now is we have to implement a procedure which is going to load the items from our local storage. So what I'm going to do is I'm going to copy my products server and I'm going to paste it in the checkout. So now our checkout has procedures. Ours is going to be much simpler.
You can remove this import. Rename this to CheckoutRouter. You can remove GetOne entirely. And we're going to call this GetProducts because This will be accessed with checkout.getProducts. So in here, we have to be explicit.
We are fetching products because it's not exactly clear. So now what we're going to do here is just accept an array of string here. Let's go ahead and modify this so it's a much simpler query. So just this. Like that.
We don't need where, sort, page, or limit. We will need where, So let's add where id in input.ids. So it needs to match. There we go. Now what I want to do here is maybe not immediately, but later on, we want to do some validation in case the user's data is invalid, in case their local storage is compromised in some way.
And we can actually do that quite easily by doing if data, total documents, does not match input ID's length, meaning we couldn't load all items. Let's throw new PRPC error from the server with code not found and a message products not found. And then we're going to capture this inside of useEffect and we're going to clear the user's local storage from there so they don't have those invalid elements anymore, but we can do that later. Now that we have the checkout router, let's go inside of trpc, routers, and underscore app. And let's add checkout, checkout router.
Make sure you add the import. Now that we have the checkout router, we can go back and set up our checkout view, which we've started developing. Mark this as use client. Go ahead and add use TRPC. Go ahead and add use query.
So no use suspense query here, no hydration here, and no prefetching here, because this is from the local storage inside, right? So when I go to checkout, and when I go to get products and when I add the query options in here I have to pass IDs right and we're gonna get these IDs by using use cart and passing in the tenant slug here. And then in here, I will be able to extract product IDs. And I can then add them here. And instead of tenant slug, let's do JSON stringify data null 2.
And there we go. You can see that my product name here is John's product, which is exactly what's in my cart. There we go. But let's imagine a scenario. What if our data gets deleted?
Let's say this product gets deleted, but we have it in our cart. We shouldn't allow the user to purchase that. So instead, what we're going to do is we're going to add a... Let me see. Maybe I can do an onError here.
I don't think I can do an onError in here. Let me check. OnError. I cannot do it. Instead, what I'm going to do is I'm going to capture the use effect here.
And I'm going to be looking for the error here. So I'm going to put it here. And if error.code is not found, let's do it like this. If there is no error, return. Let me just see, How does the error look like?
Error.data.code. So if there is no, if error.data, I can do this, I think. If error.data.code is not found, in this case, I want to do clear all carts. And we can add that here as well. Basically, I'm trying to help the user to remove invalid data if they have it.
And it should be great that the user has some feedback here. So let's import use toast or toast from Sonar. So if we do that, we're going to do toast.warning, cart cleared, Maybe first invalid products found, cart cleared. Basically, so the user is aware that something just happened, right? So by default, nothing should really happen because all of our products are here.
But let me go ahead and do the following. I'm going to go to localhost 3000 admin, and I'm going to delete this product that I have in my cart. So in order to do that, I have to log out and log in as admin. Then I'm going to go inside of the products and there we go, John's product. I'm going to delete it now.
So what happens now? Our local storage is independent of the user, so I still have it. So I'm manually going to go to slash tenants slash John. So this is John's shop. And you can see that I have something in my cart here because it's in my local storage.
So now what should happen here, it's now getting errors, but It's retrying three times. And there we go. Invalid products found. Cart has been cleared. And you can see that I have no items here.
That is exactly the behavior we wanted. So now when you click Continue Shopping here, you can see you no longer have anything in your cart. And you can also see that inside of your application here, you can see my tenant carts are completely empty. So I did a brute force here, because this shouldn't really happen that often. But if it does, we're just going to clear all carts, right?
If there's a single thing we cannot found from the array that the user has provided us with in comparison to what we are searching for in the database, obviously, some IDs are invalid, or maybe they were deleted, right? So we are passing that message along to the front end, and this captures it and says, OK, I have to clear all cards. You can decide if you want this behavior or not. So feel free to comment it out if you do not like this solution. Great.
Now that we have established this, I'm going to go ahead and add some products. And I'm going to go back to my checkout view. All right. So I have created two items in Antonio here. So I'm going to go ahead, and I'm going to add both of them to the cart.
So now I have two items in here, and I load two items in here. And since the data total documents match the amount of IDs I have submitted, you can see that it works just fine with no problems. I'm going to keep an eye on this solution because it is quite brute-forcey, so I don't ever want this to run if it doesn't need to run. So if it's causing you any problems, you can of course comment it out and remove it and solve the error in some other way. Now let's go ahead and let's display this in a nicer way.
So I'm going to start by giving this div some padding. Large padding 16, padding top 4, px 4, lg, px 12. Now it should be aligned with the checkout and the footer. Now let's go ahead and start displaying some checkout items here. So I'm going to create a div with a class name of grid grid columns 1 on large grid columns 7 gap 4 and on LG gap 16.
So in here we're going to have two columns. The first column will be the place where we are going to render our items. And then the second column, with a span of three, will be the checkout sidebar. So this is the checkout sidebar, and in here, we're going to render our items. Let's create a div with a class name BorderRoundedMediumOverflowHidden and background color of white.
And in here, let's do data?docs.mapProductIndex and create the checkout item. We don't have this yet, so we're going to be working on it soon. Let's pass some props. Product.id is last will be if index is equal to data docs length minus one so the index is match because indexes start at zero image URL will be product image question URL will be product.image?url. Name will be product.name.
Product URL here is going to be generate tenant URL product.tenant.slug slash products. So I have to add backticks here. Encapsulate this like that and then slash products product ID. Below that, we're going to have the tenant URL, which is going to be simpler. And no need for backticks in this case.
So we can remove this. Then we're going to have the tenant name, which will be product.tenant.name. And we're going to have the price, product.price. And we're going to have onRemove, which is going to call a method which we can destructure from here removeProduct so let's call removeProduct product.id there we go Now we have to create the checkout item component. So we're going to go ahead inside of checkout UI components and create checkout-item.tsx.
Then let's create an interface checkout item props which has is last which is an optional boolean, an optional image URL, name, product URL, tenant URL, tenant name, ID, price, and onRemove methods. We can now go ahead and export const checkout item with all of those props destructured. Now let's go ahead and start using them. Before we do that, I just want to add all imports which we are going to need. Link, image, and CNUtil.
Now inside of here, let's start by having a div with a class name CN. With the class name CN. Grid columns and inside of here add 8.5 rem underscore 1 fr underscore auto gap of 4, pr of 4, and border-bottom. And then if is last, border-bottom will be 0, so we don't have duplicate borders. Then in then add a div with a relative aspect square and height full inside of here go ahead and add an image The image will have a source of image URL or slash placeholder PNG or whatever you are using.
For the alt, you can add the name of the product, add a fill property here, and add a class name of object cover. You can now go back to the views, checkout view, and you can import the checkout item from dot dot slash components checkout item. And now, you should start seeing these items. Just wait for them to load. You can see I have two items loaded here.
Let's continue developing inside of the checkout item. So outside of this, we're going to create a new div with a class name py4 flex. Flex call and justify between. Inside an empty div without any class names, just a link with an href leading to product URL and an h4 element rendering the name of the product and the class name with font bold and underline. So now you should see the products here.
And now copy and paste this, this time using the tenant URL and the tenant name. And this can be a paragraph. And this will be font medium. Like this. Now go outside of these two divs here and create another div with a class name py4-flex, flex-column, justify-between.
And inside add a paragraph with a class name font-medium. Inside of here, format currency, which we have from our libutils, and pass in the price. And then add a native button element with a class name, underline and font medium and give it an on click of on remove and a type of button and inside right remove there we go you can see that now we load all of our items here and if I click remove on one of them of course if you want to you can add cursor pointer here so if you click remove you can see that it causes a reload of the data here so we can decide if we want that or not. But yeah, I think it's okay if it reloads because product IDs have changed. So it re-triggers this.
And in case, you know, in the meantime, something happened with... If in the meantime, the product was deleted, we might capture that in this error. There's also one other place where we could do this and that is in the actual checkout. So if we try to checkout and we notice some errors, then maybe we could clear the cards. But yeah, I kind of like this for now.
Let me see if there is a place where I should be using the ID. I actually don't think I need the ID anywhere, so I'm going to remove it from here and from here, which means in the checkout view, we can remove it from here. Great. Now, let's go ahead and let's implement the CheckoutSidebar component. So the CheckoutSidebar will be rendered in here.
Let's do CheckoutSidebar. And then let's go ahead and give it some props. Total will be data, documents, reduce. Now that I think of it, maybe we should get this from the server itself, right? So maybe we can do that here alongside all the data.
Maybe also add total price. And let's do data documents reduce, get the accumulator and the product, and simply combine the accumulator with the product dot price and set the accumulator to be zero by default And now we have total price from the API. So maybe we should just do data.totalPrice. There we go. We don't have to do it on the front end now.
For the checkout here, actually, let's call this onCheckout. We are going to leave it as an empty arrow function for now. IsCancelled will be for now false. IsPending will be false as well. I'm never sure if cancelled is supposed to be with two L's or one, but I think Stripe uses a single L, so I will use a single L as well.
Now let's go ahead and create the checkout sidebar component. So inside of here, checkout-sidebar.tsx. Let's create the props with total checkout, actually on checkout. Optional is cancelled and is pending. Then we can create a component with that, like this.
And let's go ahead and return a div with a class name of BorderRoundedMDOverflowHiddenBackgroundWhiteFlexFlexColumn and in here a div with a class name flex item center justified between padding four and border bottom an h4 element here rendering the text total and the class name font-medium and text-large here. Below that, instead of a heading, we're going to have a paragraph with the same classes and it's simply going to format currency here, total. So import format currency from here. And now outside of this div, we're going to create another div with a class name, adding for flex item center and justify center. And in here, add a button from components UI button.
So just make sure you have that import. And let's give it some props. Variant elevated, disabled is pending. On click on checkout. Let me fix the typo inside of the onclick here.
Size large and class name, text base, full width, text white, background primary, hover background pink, 400 and hover text primary. And inside the text Checkout, like this. Now let's go to the Checkout view here and import the Checkout sidebar from components Checkout sidebar and refresh this page. And as you can see, at the moment it was not a number, but then eventually it turns into a number. So now if we want to, we can add some proper loading states here so that this actually looks a little bit better.
So what I'm going to do here is I'm going to add two states. The first one will be if there is no data at all. So if there is no data or if data documents.length is equal to zero, in that case, let's go ahead and let's return the same thing we return in the product list view. Let me just find it. Product list view, product list.
So in here, we have this logic here to return this with an inbox icon of no products found we can do that here and let me just remove the let me make this better there we go and just get the inbox icon if you want you can also create this in a separate component like empty state or something And maybe we should do this data question mark docs dot length equals zero or data total docs is equal to zero even quicker. So you can see that it's not going to show by default, it will only show if it's loaded and if there is no data. And now let's go ahead and let's create the loading state here. So the loading state will probably happen before this. So if this I have to extract is loading from here, so is loading.
If is loading. In that case, let's go ahead and let's do a similar thing like this. Maybe I can copy this and instead of this, I will just add a loader icon from Lucid React and give it a class name, TextMutedForeground and AnimateSpin. So I've imported loader icon from Lucid React. I have no idea how this is going to look like.
Let me do a hard refresh here. Looks OK, but I think some things here are missing. Yes, I know what's missing. This wrapper. So I'm going to copy this wrapper and add it here.
And I will do the same thing here. And now I think it should look better. So let's do a hard refresh. There we go. This is the loading state and then it loads.
If I click remove, it says no products found. Great. So we now have a very nice checkout screen, which may I say is also an intelligent checkout because it will notice if we have any invalid, if we didn't manage to find some products, which shouldn't happen just like that. We are very specifically throwing that error if that happens. If it's causing you any problems, if you see your card being cleared all the time and you don't know why, maybe it's this.
Maybe our implementation isn't exactly correct. And there's one more thing that we have to do here, which is the isCancelled state in the checkout sidebar here. We're going to implement, I mean, we can implement it now, but it's basically what will appear if you cancel your checkout. So you can do isCanceled here, like this. And then add a div with a class name padding for flex justify center, items center and border top.
And in here, we can do, we can add an error message. So class name, background red 100, border, border red 400, font medium, BX of four, BY of three, rounded, relative. Actually, no need for relative flex item center and then inside of here just a div with a class name flex and item center. Render the circle X icon from Lucid React and give it a class name of size 6mr of 2, fill red 500 and text red 100. And below it, we're going to render our message, which is going to be checkout failed, please try again.
And let's give this div a width of full. So now, if you go to the checkout view and you change the sidebar, if you change the sidebar isCancelled to true, you should see checkoutFailed, please try again. Now let's just see what this is about. In here we have a type error for the checkout sidebar because this is expecting a type of number, but this could potentially be undefined. So let's just do total price or zero here.
Maybe we can do it even better inside of our get products here, where we did the calculation. Yeah, let's do this. So const total price. We can do the exact same method here. Let's do data docs dot reduce accumulator and product here.
And instead of immediate return, let's go ahead and do const price and ensure that it's a number. So product dot price and then return accumulator plus. And in parentheses, if it is not a number, we're just going to assume it's zero and then price. And it looks like this Cannot work the way I imagined it to work. Maybe I need to add zero here.
There we go. So make sure you add the initial accumulator to be zero. And now the total price should, I think, always be a number. Let me see if that actually, yeah, but I'm not sure if that really changes here, because data can potentially be undefined. So we didn't even have to do that change.
Just did this. OK. And because you don't even see that, because it's loading, right? So now it's loading, it's loading, and there we go. We have total price here, And we also have the elements we load here.
Amazing, we can remove these elements. I'm not sure, yeah, this reloading, every time we remove it, maybe I will explore improving that in some other way. But for now, I feel pretty confident in this. Great, so we have that resolved and in the next chapter we're going to connect Stripe. So we've implemented the checkout page, we've implemented the checkout layout and we have implemented the checkout procedures here.
That's it. So 19 checkout page. Let's go ahead and do a git checkout new branch 19 checkout page. Git add, git commit 19 checkout page, and git push u origin 19 checkout page. Once you've confirmed you are on the new branch, and here, and you can see the detachment, You can go to ecommerce, your github, create a new pull request here and let's review our changes.
So the summary. We introduced a comprehensive checkout experience with a dedicated page and layout featuring a responsive navigation bar. Excellent, so here is the walkthrough. We introduced a new checkout feature with tenant-specific context. We added several new React components to handle asynchronous slug resolution and render a structured layout and navigation elements.
A TRPC router with get products procedure is added to the fetch and process product data. As always, file by file change here. The sequence diagram describing how our checkout view works and how it loads the data. And some useful comments. In here, this is what we talked about previously.
AI models don't yet know Tailwind version 4, so this is actually correct. Our version is correct. This one is unneeded here. For the checkout, we know that this is just demo so we can leave it like this. And in here it made a good suggestion.
It recommends clearing only the cart of the user instead of clearing all carts. So perhaps we could do that. The reason I opted for all carts is simply to have a way to invalidate that entire local storage key, right? So that's why I went a bit aggressive here. But if you want to, you can see what the AI says, and they recommend clearing the individual cart, which definitely makes sense.
I'm going to think about it and see in the next chapter what I will do. For now, I'm going to merge this pull request. I am satisfied with all the changes. I'm not going to delete the branch, instead I will just confirm that I have it here. And then as always, git checkout back to my main or master branch.
And I'm doing git pull origin on my main or master branch. Git status to confirm, and the graph to confirm the merge as well. That's it. We have merged our changes. Amazing job, and see you in the next chapter.