So as we are nearing our final chapters here and deployment, I want to dedicate this chapter to some general improvements and some things that we have forgot to implement so far. As always, ensure that you're on your master branch and ensure that you're up to date. After that you can go ahead and do bun run dev. What I would recommend is that you go inside of your admin account and go inside of your products and simply delete all products. After that you can go ahead and log out and feel free to log in as an account with whom you have verified your account so that you can create new products.
The first thing I want to do is give tenants ability to soft delete or archive the products. Right now, if a tenant created a new product, so I'm going to call this John's public product with a price of $99 and business and money category. And I click Save here. You can see that I have the option to delete this. That will cause problems if someone has an order for that product.
If they go into their library, they will have an error if they try to load that order. So because of that, I would rather we implement soft delete rather than hard delete. We will still allow hard delete but only for super admins. In order to do that, let's go inside of our products collections and first let's disable the delete. So make sure that only if the user is a super admin do we allow delete.
So if you refresh now, you should no longer have the delete option. Instead, what we are going to do is we are going to add a new field with name of is archived, label of archive, this is important, a default value of false. And then let's go ahead and also add a type of checkbox. And then you can add an admin and a description here check if you want to delete or hide this product like this or maybe if checked this product will be archived. So just something to give the user an idea of what will happen.
So right now as you can see if I click archive and save nothing much will change on my homepage. The product is still here. So what we have to do is we have to go inside of procedures for the products. Specifically, we have to go inside of get many procedures. So in here, in the global where, what we're gonna do is we're going to target isArchived here and we're going to make it not equals true.
Why not equals true? Why not use equals false? That's because when I initially developed this, I forgot to put the default value to false, which means the default value was null or undefined. And when the default value is null or undefined, equals false will not be true, and that product will not be loaded. So it's technically considered archived.
So two things that we have to learn from this, always ensure that you have the proper default value, in this case, false. And second, use not equals true rather than equals false. So if an archive is set to true, it will be omitted with this explicitly now. So if I do a refresh here, Let's go ahead and refresh one more time. And it looks like I still cannot load it.
And yes, that is because we have changed it to be archived. But If you uncheck this, now, there we go. It's right here. So not equals true works exactly as it should. What I want you to test is go and create a completely new product.
So test product, or let's call this John's public product, and simply add the price, and don't touch the archive. So just click save. And what's important is that by default, you now have two products here, right? So if you use equals to false, and if you forget to add the default value, this is what will happen. You will create a new product, testing default false missing.
For example, you will do this, and then, do you see? It doesn't exist. Why? Well, that's because In here, it is explicitly looking for isArchived to be false, which is not the case if you forget to add this. So that's why it's safer to use not equals true.
So it's looking for a reverse explicit logic. You can see that now all three are loaded properly, even though we forgot to add this. But still, use double insurance here. Use default value set to false and use not equals set to true. There we go.
So now you can safely archive this one and you can see how it's going to disappear. Great. So what I want to do now is I want to go inside of John's public product. And let's make this John's private product simply so we can test things out. So refresh.
There we go. Go inside of John's private product here. Let's go inside. And right now, as you can see, I can load this page. But if I archive John's private product I shouldn't be able to visit this page so we also have to modify the get one method This is the get one method and what we are going to do is simply throw an error.
So if product is archived, again we are explicitly looking for this to be true, in that case what you can do is throw new trpc error here with a code of not found and a message of product not found. And let's just import the TRPC error from TRPC server. And now if you refresh here, you will get this weird looking state, right? Because we don't have proper error handling now. But there we go.
Eventually, the error will be thrown. So at least the user will no longer be able to access this. But let's go ahead and let's just improve this a little bit. First thing I want to add is the loading state. So let's go inside of app, app, tenants, home, product, product ID.
So as you can see in here we are using use suspense query but we forgot to suspense actually, right? So let's add the So let's add the suspense from React. And now we have to add a fallback here, loading. Like this. And now already this should look just a little bit better, right?
You can of course modify this even further and create a better loading state. For example, in here you can export const product view skeleton. And what you can do is just copy the beginning here, the image, like this. You don't need anything more than that. So just return that and add the missing closing divs.
And in here, just use the placeholder, and for the name, you can also add placeholder. And then you can use the product view skeleton instead of this. And it's already going to look a little bit better. There we go. So it's basically loading a placeholder picture.
So now this is actually loading until it throws an error. So we still haven't fixed that part, and we do that by creating an error.tsx, like this. Go ahead and create a error page. Don't call it error, because error is a reserved keyword. And it's important that you mark this as UseClient.
And then inside of here, what I want to do is I want to just visit any product list component, preferably the one in the products module, and find the empty state, and copy it, and paste it here. And instead of inbox icon, use the triangle alert icon and instead of no products found you can do something went wrong and then we just have to add some padding here So I'm just going to copy this padding and add it here. There we go. And now we have a nice error page. So you can refresh here and you will see that when something goes wrong, you have to wait a couple of times because react query is retrying, retrying, retrying.
There we go. Something went wrong. I think this is good enough for now. Great, so that is one thing that we had to handle. So go ahead and now uncheck the archive and click save here and refresh this page.
And this time add it to cart and now go to the cart here and let's go ahead and do the following first confirm that you can activate the checkout normally you don't have to go through the checkout just confirm that you can activate it. And now go ahead and archive this and click save. So what we should do now is throw an error if you try to checkout. So let's go inside of the checkout procedures here and inside of here find the purchase protected procedure and here where you search for products go ahead and add another query here. And that's going to be isArchived not equals true.
So again, we're using the exact same thing as our get many procedure here. Let me just scroll through it. So not equals true. We are using the reverse logic. And now if you try and click Checkout, there we go.
You will see a message, Products Not Found. Whatever you're trying to find is archived. But we can improve this even further by not even allowing this product to be loaded here because this shouldn't be loaded, right? You can see that even if user clicks here it will redirect them to this error page after it loads. Let's just wait a second, there we go.
So that's what I want to do here and in order to fix that we are also inside of the checkout procedures scroll down to get products and inside of here you can go ahead and add and go ahead and move this one into the first query and then in here do the same thing. IsArchived not equals true. So it's important to use this reverse logic and it's even more important to have the default value. Otherwise, a lot of things could go wrong. So now when I do a refresh, you will see that it's trying to load, it's trying to load again, and then it will fail and it will clear my cart because that product was invalid.
Amazing. And after you do this, here's what I suggest you do. I suggest you, again, go ahead and create a complete new Test Untouched product simply so you can try out and convince yourself that everything is still working, right? So I'm going to go back to localhost. There we go.
So test untouched product is loading. I can load it. I can add it to cart. And I can click Checkout, and it actually works. You don't have to go any further.
This is enough. Amazing. So that's what we wanted. We wanted to do that sanity check so we know that everything we just changed is working correctly. So let's go again together.
We added the delete for super admins and we introduced is archived with a default value of false and then we modified two procedures I mean two files but many procedures inside. In the checkout procedure we modified purchase procedure and when we load the products that we try to purchase, we have added isArchived is not set to true. And then down here, where we actually load the products from the cart, we do the same thing, but we also added an end wrap here. So not equals to true. And then we use the exact same logic inside of get one, but in here more explicitly because it's only loading one, so we can look at the field and in here not equals true.
And we also added a product skeleton and we've added an error page and we've added a suspense. Great. So That is, I believe, a good improvement because now user can purchase a product, user can have that product in their library, and they will still be able to load it regardless if the product was deleted. If you want to, you can test out that entire flow as well, But just make sure that you have your Stripe... Let me just find the correct command.
So this is the command, but we have to change this to 3000 slash API stripe webhooks. So this is how our listener looks like. So I'm going to expand it again so you can see. Stripe listen forward to localhost 3000 API Stripe webhooks. So go ahead and try doing this.
So you can buy a product now safely. Our platform will take a fee thanks to what we've implemented in the last chapter here. So let me just ensure that this works. So I'm processing. There we go.
This works just fine. My cart should be cleared now, as you can see. And I will now go back and go inside of my library. And there we go. I can now access the content of this untouched product here.
And now, if you go in here and archive this product, what's going to happen is that on here, you cannot see it anymore. But in my library, I can see it because I have purchased it before it was deleted. So that was the whole point of this. It was to allow users to preserve their orders. So we have soft delete and we have this.
Now I want to show you how you can allow tenants to hide their products from the marketplace. We can do that quite simple again by visiting the products collection here and similarly to is archived if you want to you can set is private And this will be private. Default value is also very important, set it to false. And in here, if checked, this product will not be shown on the public storefront. So basically, it's like saying I only want this product on my store, not on the public storefront.
So right now, let's go ahead and try and do that here. So I have John's private product. Here's what I'm gonna do. I'm going to archive all others. So this one is archived, great.
And this one is archived as well. So in here, let me go continue shopping. I have John's public product and I will go inside of here and unarchive this one and click save. There we go. So John's private product and John's public product.
So both of them exist. None of them are deleted. Both of them are visible on individual John shop and on the public storefront. But let's say John wants to private their product, not archive them, they want to private them. So it's only available on their storefront.
Right now nothing changes. So what we have to do is just modify one procedure and that is inside of the products here. We have to modify the getMany. We are not going to do anything here. Instead we're going to check if we don't have the tenant so else here in that case go ahead and set is private to be not equals set to true like this basically if we are specifying a tenant that means that we are loading elements on a tenant page, right?
But if there is no tenant, That means that we are loading elements on a public storefront. So in that case, if we are loading products, let's write a comment for this so you understand. If we are loading products for public storefront, no tenant slug, make sure to not load products set to is private true using reverse not equals logic These products are exclusively private to the tenant store. There we go. So I hope this will make you understand it a little bit better.
If we are loading products for public storefront, meaning we don't have the tenant slug, we have to confirm that whatever we are loading doesn't have the is private field set to true, again, using not equals, and again, confirm that you have the default value. So the only thing that should change here is that you cannot see John's private product here on the storefront regardless if I click on the correct category. But if you click on John's shop, you can see it here. Nothing else should matter. This product isn't archived.
This isn't any role-based access control, right? So this is still a public product it's just not available on the public storefront. So that is the biggest difference. Great. So we have allowed this now.
Now I want to add rich text element content and description. So this is what I want you to do. I want you to log out and I want you to log in as admin. The reason I want you to do that is so that you can delete all orders, but do a hard delete here and go ahead and delete all products. And you can also delete all the reviews if you have them.
Basically, a clean slate, right? And then go ahead and log out and go back into John here. And this is what we're gonna do now. Do not create any new products just yet. What we're going to do is we're going to add a rich text element.
So the way we do that in Payload is by using this package right here. And if you are on the same version as me, you should already have this installed. So here I have it. Payload.cms.richtext-lexical. If you don't have it, you can go ahead and install it, but just make sure that you use the proper version.
You can do that like this. Let me just expand this. So bun, whoops, why here? Let me expand this again. So bun add, basically, exactly like this, right?
And then after you install it, if you do, you don't have to do it if you already have it here but if you have to do it then make sure you do a removal of dot next and node modules and then bun install if you already have it installed you don't have to do anything Now what you have to do next is go inside of the payload config and ensure that you use the lexical editor for your editor here. And now let's go ahead inside of our products collections and go ahead and change the content to use rich text type and change the description to use rich text type as well. Now you might encounter some errors during this period, especially if the types haven't generated yet. Or if the import map was not yet generated. You can see how it's taking a lot of time and I even have some errors here.
Don't worry about that. That just means that it's still generating here. You can see how it's actively generating new import maps. So let me try and refresh again. No errors.
Let's click create new product and there we go. I now have access to add rich text elements here. So I will try bold. I will try italic. I will try underline, like this.
And you, of course, have many more elements, but not all of these can be rendered on the front end by default. So one thing that can be, is uploads, but they are not enabled right now. So you can leave it like this. I suggest you only try bold, italic, and underline because these are available by default. So let's try rich text test and just put the price here.
And let's add a category. No need to do anything else. Let's just save this and let's go anywhere. And now you will get an error here. That's because we have to modify the product view inside of product, CYViews.
In here you should get an error. And you should change this to rich text field. Actually just rich text. I don't think you need to use rich text field. So let's import rich text.
Let me just wait a second. I have to confirm. So I think that you can just use rich text from at payload CMS rich text lexical dash react. So let's go ahead and try this now, as in the data to be data.description here. And if you refresh now, there we go.
Bold, italic, and underline. So these work perfectly. But if you were to try some headings or some more complex blocks, you will see that they are not exactly rendered the same. That's because you can extend this rich text with converters. And Converters are something that you can see, they have a lot of converters here, right?
And they also have these default converters. Converters. This will not change anything, for example. But you can play and try and creating your own. I will share with you later a good guide on extending this rich text with many more elements, right?
And we are actually going to do one extension here. So you can just remove this for now. So for example, let's go inside of the products collection here and let's go inside of the content which is rich text here. You can add the editor lexical editor from payload CMS rich text lexical and you can in here extend this with features like this, extract the default features, extract the default features and open a return an array like this and in here spread the default features and then add an upload feature from payload cms rich text lexical and in here add collections media fields whoops this is an array. And then an object name, name, and type text.
So now, you should be able to go inside of here. Let me just refresh. And I'm not sure if it will immediately work, because I think it needs to generate the, you can see I have some errors here. So just wait for it to generate again. I will pause and see if yours is not generating, no matter how much you wait.
You might have to go to your package.json here and run this script. So I think that now if I refresh one more time, perhaps we can try something. So for example, here is my upload, which only orders can see. And then in here, let's add an upload. And in here, it says no upload connection enabled.
But I'm pretty sure that I was certain that this will work. Perhaps I can't add it here. I have to add it to the config globally. I have to enable it here. So let's do it like that.
Let's copy this and paste this config inside. And import upload feature from payload.cms. Where is it? It's here, upload feature. So I basically just copied exactly what I did here.
And for now I will remove the lexical editor here and remove these two. Give it a chance to re-render. And let's try again. So I'm going to go back here. I'm going to leave this, refresh.
Let's go inside of here. And let's go inside of content, and let's try upload again. And still, no upload collections enabled. OK, I'm just going to pause a bit to debug why this is happening. OK, I figured out what it was.
So I tried changing the text here. Previously, we had names. So I've changed it to alt because I thought maybe the text is the problem. And here's what I learned. So if you try and add upload like this, it's telling you no collections enabled.
And I think it's because of our media collection. We hide it from non-admins. So if I allow it here, like this, there we go. You can see that now I am allowed to do that. So I'm just going to add an image, for example, or a file.
So I just imported a random PDF. And there we go, I added that PDF here, I entered my previous rich text here. And let's click Save. And let's go ahead and go through the purchase. So the only thing I'm concerned with now is that we need to have media enabled here in the sidebar, right?
So, yeah, maybe I will just end up disabling it altogether, but there is definitely a way, you know, to have it fixed to the tenant, perhaps. So maybe we could go to the config here, and we could add media here as well. So media also belongs to the tenant. That's one idea. The problem is I don't know what will happen if I just change media here.
I don't know the implications of that, but I'm just trying to show you all that you can do here. So let's have our Stripe webhook running And let's try purchasing the rich text test here. So I'm going to go here. Right now, you can see I don't see that secret content. And now, when I go through here and purchase the content, it should start appearing in my library.
And in there, I should be having this super secret content right but anyway you know in the content you can add any links whatever you want to share with the users who have purchased right So let's go inside of Funroad library in here. And I think we will now get an error here because we also need to modify the product view here in the library. So let's go inside of here. If we have data content, we use rich text and paste it, passing the data, data content. Make sure you have this import here, and then refresh here.
And there we go. You can click on this, and it will open and download a file. Great, so we enabled that now. But again, I'm now pretty sure that if I log out and if I go inside of AntonioDemo.com, so some other user, and go inside of media, you can see that I can see the invoice. So my idea was to, let's do this, let's go inside of johndemo.com and demo here.
So my idea was to just delete this, I can't delete them. Let me try an admin. I'm just gonna delete all media and I'm going to delete all products And I'm going to delete all orders. So basically, I have no conflicts, no missing data. So let me try going to AntonioDemo.com.
Actually, I have to go inside of John because it's the only one who has verified their business. And I will try going to the payload config and simply adding media here. Basically, I want to ensure that media can only belong to a tenant, right? So I don't want it to be shared. Now I'm going to give this time to refresh because I think it also needs to generate new import maps and whatnot.
And let's do a refresh here. And I'm going to create a new product now. Test sharing media. And this is my description. And I think we can try an upload here.
So again, just a random invoice here with a test here like this. Let's add a price and let's click Save. Oops, test. And yeah, I'm not sure this is working as it should. So what I'm just gonna do is I'm gonna remove the media from the multi-tenant plugin.
And I will also remove it from the config here in the lexical editor. And I will just keep the lexical editor as it was. But I just wanted to give you an idea of how you can extend this editor, right? The problem we have now is that our media gets shared and that's not something I want. So I will bring this back from the media collection.
And now media should no longer be available to anyone who is not an admin. OK, so let me go back to my admin account here. And I recommend that you do it as well. Just clear everything so it's easier to work with so you don't have any stale data here. There we go.
So let's go ahead and see if we have anything left here. So we definitely added the rich text elements. That's great. We can now provide our users with a better experience. And now let's add missing suspense wrappers, right?
So I've noticed we have a couple of them. We've actually added one of them. Let me pull this down. We have actually added one of them, which is inside of the product view here, or not. No, it's here in the page, right?
We had a missing suspense. What does it mean that we have a missing suspense? It means we have a product which uses a use suspense query, but it's not wrapped in suspense. So let's search for use suspense query and let's check. So this one is called search filters and if I scroll down I can see that I have a skeleton for that which most likely means that search filters is properly wrapped in a suspense.
Let's click on the review sidebar. Review sidebar seems to not have that. So I will go inside of the review form here. And I'm going to copy this form. And I'm going to copy all the way here to the button and where the form ends.
And I will export const review form skeleton here. Let's return and paste all of this. Change the form to a div, remove the on submit. You can prettify this now. This will not be dynamic, we are going to assume that the loading state will look like this.
So you can also change the bottom one to a div. Remove the conditional here, the button will definitely be rendered and it will be disabled. And in here we are going to say post review. The type will be button. In here you can go ahead and remove everything besides the text area, so just the field itself and simply set it to be disabled.
And you can do the same thing for the star picker. So we just need this, we don't need anything else. Remove this, remove this and simply set it to be disabled. You can even format it like this. There we go.
Now we have the review form skeleton, so we can go inside of the review sidebar, inside of the page here. Whoops. Let's see, where do we use the review sidebar? In the product view, specifically library UI views product view. Let's wrap it inside of suspense here.
Add a fallback, review form skeleton, and a self-closing tag. Make sure you have imported suspense from react and review form skeleton. So even though we are wrapping the review sidebar, it's okay to use the review form skeleton because we know that's the only thing that's returned. So let's use use suspense query again to see if there's something we can improve here. We just did the review sidebar.
And now let's click here. So this is the product view. The product view here is missing a skeleton. So this is the modules library product view. So I will just copy all the way to...
I will just copy the div here. That's it. So export const product view skeleton. And just close the div. As simple as that.
That's our loading state. And you can remove the link here. And actually you can transform it into a div so it's not clickable while in the skeleton phase. Now what you have to do is you have to find where you render the product view and I know that we you can do it easily by doing this or you can just look at where I added. So product view, you can find it in app, app library, library, product ID.
That's the one with the missing suspense. From React, fallback, product view, skeleton. Be careful. Import the one from the library it's a self-closing tag and make sure you're wrapping this correctly like this Let's continue our use suspense query search here. This is the second one but I think that this product view has a skeleton already.
Let me scroll all the way down. So yes, this is the one that we just added. And this is the navbar. Let's see if the navbar has a skeleton. It does.
Perfect. And I think that we can also search for hydration boundary. And this way, we can see if any suspense is missing. Not everything needs to have a direct suspense. For example, product list view has a suspense here.
So It matters where you use the use suspense hook, but still, this is a good way to check. So I will definitely always check, but you can see that I use it inside. These product list views are repeating themselves, so all of them are safe. Library view, let's see that. We use suspense here, so that's fine.
In here, we use suspense clearly here. Hydration boundary suspense, that is fine. In here, we have the product list view. And we use suspense around product list, that is fine. And in here we use suspense.
Excellent. So now we are handling all of those suspense cases. So we can mark this as completed as well. Let's go ahead and commit these general improvements now. So I'm going to git checkout v26 general-improvements, git add, git commit 26 general-improvements and git push uorigin26 general improvements like this.
Now we can go ahead into your GitHub and go ahead and open the new pull request. Create a new pull request and let's see our changes. And here we have the summary. We introduced a loading placeholder for product details and review forms, basically our new suspense wrappers. We added an error display page that provides clear user-friendly error messages.
So we added this on the product ID page, which is the one page that I think is most likely for something to go wrong, especially if you cannot find that product page. And we enhanced product descriptions with rich text formatting for a better viewing experience. And we also improved product filtering to hide archived and private products from the public storefront. We also did some refactoring as you've seen in the last part of this tutorial. As always, in-depth review here.
And we have two sequence diagrams here. So in here we can see some fallbacks for our product review skeleton. And in here we have the logic for our archived and private videos and how our TRPC server behaves there. And no comments besides some nitpick comments. So we did a very good job here let's go ahead and merge these changes and let's confirm that we have that 26 there we go and now I'm just going to go back to my branch get full origin master like this or main depending on what you use and git status to confirm that you are up to date and there we go amazing amazing job and see you in the next chapter which is one of the last chapters we are going to do.
Not the last, but one of the last chapters.