Apps
To learn the platform, let’s create an app that covers a range of surfaces in Swell. This app will be comprehensive enough to show you how to implement each pattern with concrete examples.
We’ll call it Honest Reviews. The scope of our app will include the following:
- Dashboard elements to view and manage customer-submitted reviews.
- List of reviews with approval stages.
- Product fields for ratings and related reviews.
- Customer reviewer profile with photo, bio, and related reviews and comments.
- Settings to manage app behavior.
- A reward system to incentivize customers to submit reviews.
- Settings to configure granting account credit for approved reviews.
- App functions to handle scoring, auto-approvals, rewards, and more.
- Admin and user notifications that are sent when reviews and submitted and approved.
- A store frontend to display products along with their ratings, to submit reviews and comments, and more.
→ Find the full
Honest Reviews serves as a showcase of the many features you can use to build Swell Apps, however it is not actively maintained. Pull requests are welcome.
To follow along this guide, get the Honest Reviews app installed.
First, you’ll need the Swell CLI and a Swell account. First, install the CLI and log in.
npm install -g @swell/cli
swell login
Next, clone the example app and push it to your test environment.
git clone git@github.com:swellstores/honest-reviews-app.git
cd honest-reviews-app
npm install
swell app push
Once installed, you can use the link in the CLI to navigate to the app details page in your dashboard. You should also see “Honest Reviews” appear as an option in your left navigation under Products. Various tabs, columns, and fields should become visible as well.
Finally, add an API key and run the storefront app locally.
cd /path/to/honest-reviews/storefront/
cat >> .env.local << EOF
NEXT_PUBLIC_SWELL_STORE_ID=<your_store_id>
NEXT_PUBLIC_SWELL_STORE_KEY=<your_public_key>
EOF
npm install
npm run dev
Use the CLI to push configuration and function changes from to the test store.
Push all changes with swell app push.
swell app push
[store] test [env] test
App details
✔ swell.json
✔ asserts/icon.png
Data models
✔ models/reviews.json
...
Pushed 28 configurations to Honest Reviews. View the Honest Reviews app in your dashboard at https://example.swell.store/admin/test/apps/...
Watch for changes with swell app watch.
swell app watch
[store] test [env] test
Watching for changes...
✔ Pushed models/reviews.json
✔ Pushed functions/review-created.ts
When testing, view the logs from your dashboard under Developer > Console (Logs tab), or using the swell logs CLI command.
Often the easiest way to start building an app is to first consider the data schema. A Swell app can add fields to a store with both Content models and Data models. Content models represent dashboard elements, but they also create data models and fields automatically in the background to match your inputs, while data models give you additional flexibility. Both model types can overlap on the same collection in order to finely control backend vs dashboard behavior.
Let’s start by creating a data model for Reviews in models/reviews.json. The following is a basic shape that we’ll continue adding onto as we go.
{
"fields": {
"account_id": {
"type": "objectid",
"required": true
},
"account": {
"type": "link",
"model": "accounts",
"key": "account_id"
},
"product_id": {
"type": "objectid",
"required": true
},
"product": {
"type": "link",
"model": "products",
"key": "product_id"
},
"title": {
"type": "string",
"required": true
},
"body": {
"type": "string",
"required": true
},
"images": {
"type": "array",
"value_type": "file"
},
"rating": {
"type": "int",
"min": 1,
"max": 5,
"required": true
},
"status": {
"type": "string",
"enum": ["submitted", "approved", "rejected"],
"default": "submitted"
},
"rejected_reason": {
"type": "string"
},
"featured": {
"type": "bool",
"default": false
}
}
}
In summary, the basic Reviews model has:
- Reference to the product being reviewed
- Reference to the account which submitted the review
- Title, body, and array of images to represent review content
- Review rating
- Status for the admin to manage
- Description to notify the user why a review may have been rejected
- Featured flag for the admin to manage
When this model is created, we now have access to a new collection and API endpoint at /apps/honest_reviews/reviews. The collection can also be accessed at /reviews when using an API key with permission scope assigned to the app.
Now to make this content editable in the admin dashboard, let’s create a content model for Reviews in content/reviews.json.
{
"collection": "reviews",
"fields": [
{
"id": "account",
"label": "Reviewer",
"type": "customer_lookup",
"description": "The customer who wrote the review",
"required": true
},
{
"id": "product",
"label": "Product",
"type": "product_lookup",
"description": "The product being reviewed",
"required": true
},
{
"id": "title",
"label": "Review title",
"type": "text",
"description": "The title of the review",
"required": true
},
{
"id": "body",
"label": "Review body",
"type": "long_text",
"description": "The body of the review",
"required": true
},
{
"id": "rating",
"label": "Rating",
"type": "slider",
"unit": "stars",
"min": 1,
"max": 5,
"description": "Rating (1-5) of the reviewed product",
"admin_span": 1,
"required": true
},
{
"id": "images",
"label": "Images",
"type": "image",
"description": "Images associated with the review",
"multi": true,
"conditions": {
"$settings.images.enabled": true
}
},
{
"id": "status",
"label": "Status",
"type": "select",
"description": "Admin status of the review",
"default": "submitted",
"admin_span": 2,
"options": [
{
"label": "Submitted",
"value": "submitted"
},
{
"label": "Approved",
"value": "approved"
},
{
"label": "Rejected",
"value": "rejected"
}
]
},
{
"id": "rejected_reason",
"label": "Rejected reason",
"type": "long_text",
"description": "Reason for rejecting the review, may be visible to the customer",
"conditions": {
"status": "rejected"
}
},
{
"id": "featured",
"label": "Featured",
"type": "toggle",
"description": "Indicates the review may be featured in your storefront",
"conditions": {
"$settings.featured.enabled": true
}
}
],
"views": [
{
"id": "list",
"nav": {
"parent": "products",
"label": "Honest Reviews"
}
}
]
}
The Reviews content model defines how the fields should be displayed in the admin dashboard. All the fields above overlap with fields we defined in the data model. We’re just getting started, but note that if we hadn’t defined the data model, the platform would automatically create a data model with a matching schema, but there will be good reasons to have both in our app as we continue to enhance it.
Also note that if a content model field exists for a field that is not specified in a data model configuration, the platform will add the field to the generated data model for you. In other words, you don’t necessarily need to worry about keeping them in sync, but you will receive an error if the field definitions conflict in a significant way.
The views property near the end of the content model has several purposes, and one of them is to indicate whether and where the collection list should be represented in the left navigation. In the Honest Reviews app, we placed a link to the list view under the products section.
Each navigation section has an ID that matches the name, in lowercase.
With the basics covered, let’s add fields to represent the remaining review features.
- Rewards
- Reactions
- Comments
- Verified buyers
{
...
"fields": [
...
{
"id": "reward_amount",
"label": "Reward amount",
"type": "currency",
"description": "Amount to reward the customer for writing the review",
"admin_span": 1,
"conditions": {
"$settings.rewards.enabled": true,
"status": "approved",
"reward_disabled": { "$ne": true },
"rewarded": { "$ne": true }
}
},
{
"id": "reward_disabled",
"label": "Disable reward",
"type": "toggle",
"conditions": {
"$settings.rewards.enabled": true,
"status": "approved",
"rewarded": { "$ne": true }
}
},
{
"id": "verified_buyer",
"label": "Verified buyer",
"type": "toggle",
"description": "Indicates the reviewer has purchased the product",
"conditions": {
"$settings.verified.enabled": true
}
},
{
"id": "featured",
"label": "Featured",
"type": "toggle",
"description": "Indicates the review may be featured in your storefront",
"conditions": {
"$settings.featured.enabled": true
}
},
{
"type": "field_row",
"fields": [
{
"id": "like_count",
"label": "Like count",
"type": "number",
"description": "Indicates the number of likes the review has received",
"readonly": true
},
{
"id": "dislike_count",
"label": "Dislike count",
"type": "number",
"description": "Indicates the number of dislikes the review has received",
"readonly": true
}
]
}
...
]
...
}
To summarize:
- Like/dislike count of reactions
- Reward amount and flag to disable it manually
- Verified buyer flag
- Comments collection
- Reactions collection
Now let's configure the interface to manage this content in the Swell dashboard.
{
...
"fields": {
...
"like_count": {
"type": "int",
"default": 0
},
"dislike_count": {
"type": "int",
"default": 0
},
"rewarded": {
"type": "bool",
"default": false,
"private": true
},
"reward_amount": {
"type": "currency",
"localized": true,
"private": true
},
"reward_disabled": {
"type": "bool",
"private": true
},
"verified_buyer": {
"type": "bool",
"default": false
},
"comments": {
"type": "collection",
"fields": {
"account_id": {
"type": "objectid",
"required": true
},
"account": {
"type": "link",
"model": "accounts",
"key": "account_id"
},
"body": {
"type": "string",
"required": true
},
"status": {
"type": "string",
"enum": ["submitted", "approved", "rejected"],
"default": "submitted"
},
"like_count": {
"type": "int",
"default": 0
},
"dislike_count": {
"type": "int",
"default": 0
},
"score": {
"type": "int",
"formula": "like_count - dislike_count"
},
"verified_buyer": {
"type": "bool",
"default": false
}
}
},
"reactions": {
"type": "collection",
"public": true,
"fields": {
"account_id": {
"type": "objectid",
"required": true,
"unique": "parent_id"
},
"liked": {
"type": "bool",
"required": true
},
"comment_id": {
"type": "objectid"
}
}
}
...
}
}
Next, let’s create a content model for Customers (accounts) in content/accounts.json to represent the reviewer profile features.
{
"collection": "accounts",
"fields": [
{
"id": "name",
"label": "Display name",
"type": "text",
"description": "Name of the customer displayed with reviews",
"default": "{{ record.name }}",
"fallback": true,
"public": true
},
{
"id": "photo",
"label": "Photo",
"type": "image",
"description": "Photo of the customer displayed with reviews",
"conditions": {
"$settings.photo.enabled": true
},
"public": true
},
{
"id": "about",
"label": "About me",
"type": "long_text",
"description": "Reviewer bio displayed with reviews",
"public": true
},
{
"id": "reviews",
"label": "Reviews",
"type": "collection",
"description": "Reviews submitted by this reviewer",
"collection": "reviews",
"link": {
"params": {
"account_id": "id"
}
}
},
{
"id": "review_count",
"label": "Review count",
"type": "integer",
"description": "Total number of reviews submitted by this reviewer",
"readonly": true,
"public": true
},
{
"id": "comments",
"label": "Comments",
"type": "collection",
"description": "Comments submitted by this reviewer",
"collection": "reviews:comments",
"link": {
"params": {
"account_id": "id"
}
}
},
{
"id": "comment_count",
"label": "Comment count",
"type": "integer",
"description": "Total number of comments submitted by this reviewer",
"readonly": true,
"public": true
},
{
"id": "score",
"label": "Reviewer score",
"type": "integer",
"description": "Sum of reactions to reviews and comments made by other customers",
"readonly": true,
"public": true
},
{
"id": "reward_total",
"label": "Reward total",
"type": "currency",
"description": "Total amount of rewards earned by the reviewer",
"readonly": true,
"localized": true
}
]
}
To summarize:
- Display name shown on the frontend with reviews, which defaults to their real name
- Photo to display in their profile and with reviews
- About me bio to encourage engagement with other customers
- Reference to reviews submitted by the user
- Reference to comment made by the user
- Score representing the sum of reactions to the reviews they have submitted
- Total amount of rewards granted for approved reviews
In addition to the content fields, let’s add a couple of backend fields to associate reward credits to the reviews they were granted for. We won’t necessarily display these fields in the dashboard, but it makes our schema more complete for future enhancements.
{
"fields": {
"credits": {
"fields": {
"review_id": {
"type": "objectid"
},
"reward_id": {
"type": "objectid"
}
}
}
}
}
In this case we aren’t adding any of the fields to the data model that were defined by the content model. As mentioned earlier, the platform will automatically generate a data model (or add fields to an existing standard model such as accounts). When this configuration is pushed to Swell, it will merge these fields with your existing schema as expected.
The fields are being defined within the credits field, which is a standard child collection on the Accounts model. Also note that app-defined fields on a standard model ultimately have their own namespace, and there is no chance for conflict. The review_id field on an account credit record will be available as $app.honest_reviews.review_id. Because Accounts is a standard model, all app-defined fields have this kind of namespace, whereas they would not on an app-defined model because the model itself has its own namespace.
You might be wondering, why did we define a complete data model for Reviews, and not for Accounts? It’s really up to preference, but there are some key reasons to create a data model, including:
- Custom events
- Public field queries
- Formulas and other backend-specific attributes
Next, let’s create a content model for Products in content/products.json.
{
"collection": "products",
"fields": [
{
"id": "rating",
"label": "Review rating",
"type": "number",
"digits": 1,
"description": "Average rating of reviews approved for this product",
"readonly": true
},
{
"id": "review_count",
"label": "Review count",
"type": "integer",
"description": "Total number of reviews approved for this product",
"readonly": true
},
{
"id": "honest_reviews",
"label": "Reviews",
"type": "collection",
"description": "Reviews submitted for this product",
"collection": "reviews",
"link": {
"params": {
"product_id": "id"
}
}
}
]
}
To summarize:
- Aggregate rating of all reviews approved for the product
- Number of reviews approved for the product
- Collection of reviews submitted for the product
Note that we called the reviews collection honest_reviews because there is an existing child collection on Products called reviews, which we’ve decided not to use in the app for reasons we won’t get into in this guide.
To finish off our initial draft , let’s create a model for Rewards in models/rewards.json.
{
"label": "Rewards",
"fields": {
"account_id": {
"type": "objectid",
"required": true
},
"account": {
"type": "link",
"model": "accounts",
"key": "account_id"
},
"review_id": {
"type": "objectid",
"required": true
},
"review": {
"type": "link",
"model": "reviews",
"key": "review_id"
},
"amount": {
"type": "currency",
"required": true,
"immutable": true,
"localized": true
},
"rewarded": {
"type": "bool",
"default": false
}
}
}
To summarize:
- Reference to the account receiving a reward
- Reference to the review the reward was granted for
- Amount of the reward
- Flag to indicate the reward credit was created by the app
We won’t create a content model for Rewards at this time, although that would be a nice future improvement.
Content views are a new way to enhance the dashboard with apps. Now that we have our app models defined, we’ll add views to improve the overall admin experience with app-specific tabs, columns, and layouts for different pages.
{
...
"views": [
{
"id": "list",
"nav": {
"parent": "products",
"label": "Honest Reviews"
},
"tabs": [
{
"id": "approved",
"label": "Approved",
"query": {
"status": "approved"
}
},
{
"id": "rejected",
"label": "Rejected",
"query": {
"status": "rejected"
}
},
{
"id": "verified",
"label": "Verified buyers",
"query": {
"verified_buyer": true
}
}
],
"fields": [
{
"id": "title"
},
{
"id": "product"
},
{
"id": "body",
"truncated": 100
},
{
"id": "rating",
"template": "{{ rating }} {{ rating | default: 1 | pluralize: 'star','stars' }}"
},
{
"id": "account"
},
{
"id": "status"
},
{
"id": "comments"
},
{
"id": "date_created",
"label": "Submitted"
},
{
"id": "reward_amount",
"conditions": {
"$settings.rewards.enabled": true
}
},
{
"id": "verified_buyer",
"conditions": {
"$settings.verified.enabled": true
}
},
{
"id": "featured",
"conditions": {
"$settings.featured.enabled": true
}
}
]
},
{
"id": "new",
"fields": [
{
"type": "field_row",
"fields": [
{
"id": "account"
},
{
"id": "product"
}
]
},
{
"id": "title"
},
{
"id": "body"
},
{
"id": "rating"
},
{
"id": "images"
},
{
"id": "status"
},
{
"id": "rejected_reason"
},
{
"id": "reward_amount"
},
{
"id": "reward_disabled"
},
{
"id": "verified_buyer"
},
{
"id": "featured"
},
{
"id": "comments"
}
]
},
{
"id": "edit",
"title": "{{ title }}",
"subtitle": "By {{ account.name }}",
"fields": [
{
"type": "field_row",
"fields": [
{
"id": "account"
},
{
"id": "product"
}
]
},
{
"id": "title"
},
{
"id": "body"
},
{
"id": "rating"
},
{
"id": "images"
},
{
"id": "status"
},
{
"id": "rejected_reason"
},
{
"id": "reward_amount"
},
{
"id": "rewarded_amount",
"readonly": true,
"type": "currency",
"default": "{{ reward_amount }}",
"conditions": {
"rewarded": true,
"reward_amount": { "$gt": 0 }
}
},
{
"id": "reward_disabled"
},
{
"id": "verified_buyer"
},
{
"id": "featured"
},
{
"id": "comments"
},
{
"type": "field_row",
"conditions": {
"$settings.reactions.enabled": true
},
"fields": [
{
"id": "like_count",
"admin_span": 1
},
{
"id": "dislike_count",
"admin_span": 1
}
]
}
]
}
]
There are 3 standard views, list, edit, and new. Each of them can be used to incrementally improve the experience with save defaults as a fallback.
- list views define navigation, tabs, columns and filters of a collection page. If columns are not
- edit views define the layout of a record page. If not defined, the platform creates its own layout by default.
- new views define the way the page is laid out when creating a new record. If not defined, the platforms defaults to the edit view.
All view fields will use defaults from a top-level field definition matching the id property, which is how we’re able to leave out those values normally. This behavior saves time, but it’s also common to override certain properties for different layouts such as label, description, or to make a field required in one view versus another.
To summarize the above additions:
- Added Approved, Rejected, and Verified buyer tabs to the review collection page
- Queries to filter reviews based on record data for each tab
- Defined a default order of columns on the collection page
- Defined a layout for the edit review page
- Defined a layout for the new review page
{
...
"views": [
{
"id": "list",
"tabs": [
{
"id": "reviewers",
"label": "Reviewers",
"query": {
"$or": [
{
"review_count": {
"$gt": 0
}
},
{
"comment_count": {
"$gt": 0
}
}
]
}
}
],
"fields": [
{
"id": "review_count",
"label": "Reviews"
},
{
"id": "comment_count",
"label": "Comments"
},
{
"id": "score"
},
{
"id": "photo"
}
]
},
{
"id": "edit",
"tabs": [
{
"id": "reviewer",
"label": "Reviewer profile",
"fields": [
{
"type": "field_row",
"fields": [
{
"id": "reward_total"
},
{
"id": "review_count"
},
{
"id": "comment_count"
},
{
"id": "score"
}
]
},
{
"id": "name"
},
{
"id": "photo"
},
{
"id": "about"
},
{
"id": "reviews"
},
{
"id": "comments"
}
]
}
]
}
]
}
Since Accounts is a standard model, the defined properties get added to the existing standard views. For example, the Reviewers tab will be visible next to the existing tabs in the customer collection page. As with all collections, these tabs can be sorted and hidden by an admin.
The same goes for collection fields we’ve defined, which will be visible next to the existing fields in the customer collection page.
The edit view in this case is adding a new tab to the customer record page, in order to separate the content fields from standard ones and to simplify the overall app experience.
{
...
"views": [
{
"id": "list",
"tabs": [
{
"id": "reviewed",
"label": "Reviewed",
"query": {
"review_count": {
"$gt": 0
}
},
"fields": [
{
"id": "rating"
},
{
"id": "review_count"
}
]
}
],
"fields": [
{
"id": "rating",
"format": "{{ rating }} {{ rating | default: 5 | pluralize: 'star','stars' }}"
},
{
"id": "review_count",
"label": "Reviews"
}
]
},
{
"id": "edit",
"tabs": [
{
"id": "reviews",
"label": "Reviews",
"fields": [
{
"type": "field_row",
"fields": [
{
"id": "rating",
"admin_span": 1
},
{
"id": "review_count",
"admin_span": 1
}
]
},
{
"id": "honest_reviews"
}
]
}
]
}
]
}
That completes our content models. If you’ve installed the app already, it would be useful to explore the pages in your test store and consider how the above configurations are represented in the dashboard.
We’ll have more additions to the data models in later sections of the guide.
Our app offers several features that a merchant would likely want to configure. For example, they might want to disable rewards, or disable comments, among other things. We can define app settings to be displayed in the app details page in the dashboard to support this need, and the app can use those setting values in any context.
Let’s start with settings for the merchant to configure how auto-approvals work, in settings/approval.json.
{
"label": "Approvals",
"description": "Settings that affect review approvals",
"fields": [
{
"id": "enabled",
"label": "Automatically approve reviews and comments",
"type": "toggle",
"default": false
},
{
"id": "auto_approve_min_orders",
"label": "Min. orders to approve",
"type": "number",
"digits": 0,
"description": "Only approve when the customer has placed this many orders",
"placeholder": "0",
"admin_span": 1,
"conditions": {
"approval.enabled": true
},
"default": null
}
]
}
When this configuration is pushed, a Settings tab will appear in the dashboard in Apps > Honest Reviews. Setting fields are defined using all the same properties as content fields, and are represented in the dashboard using the same UI components.
Each setting definition has a name that gets combined with others in an object that the app will operate on. The name of the file approval.json is what defines the namespace. This approach allows you to keep things in order as the app grows in functionality, and also allows the Swell dashboard to optimize the merchant experience over time by organizing setting groups in different ways.
The app will access settings in different ways depending on the context, supporting various use common use cases.
- Show/hide tabs and fields in content models
- Render different content in email notifications
- Trigger functions only when settings meets a criteria
- Enable/disable storefront functionality
- Moderate function logic
You’ll find examples on how to use settings in each context throughout this guide.
With the basics covered, let’s add settings to represent the remaining review features. We’ve organized these settings in a way to simplify the merchant interface.
- Comments
- Featured reviews
- Review images
- Reviewer profile
- Reactions
- Rewards
- Slack notifications
- Verified buyers
{
"label": "Review comments",
"description": "Allow users to comment on other customer reviews",
"fields": [
{
"id": "enabled",
"label": "Enable review comments",
"type": "toggle",
"default": true,
"public": true
}
]
}
{
"label": "Featured reviews",
"description": "Display featured reviews on home and product pages",
"fields": [
{
"id": "enabled",
"label": "Enable featured reviews",
"type": "toggle",
"public": true,
"default": true
},
{
"id": "display_count",
"label": "Number to display",
"type": "integer",
"admin_span": 1,
"default": 3,
"public": true,
"conditions": {
"featured.enabled": true
}
}
]
}
{
"label": "Review images",
"description": "Allow users to upload images along with reviews",
"fields": [
{
"id": "enabled",
"label": "Enable image uploads",
"type": "toggle",
"default": true,
"public": true
}
]
}
{
"label": "Profile photos",
"description": "Allow users to display photos on their reviewer profile",
"fields": [
{
"id": "enabled",
"label": "Enable profile photos",
"type": "toggle",
"default": true,
"public": true
}
]
}
{
"label": "Reactions",
"description": "Allow users to react to reviews and comments with likes and dislikes",
"fields": [
{
"id": "enabled",
"label": "Enable reactions",
"type": "toggle",
"default": true,
"public": true
},
{
"id": "likes_only",
"label": "Likes only",
"type": "toggle",
"description": "Only show the option to Like a review or comment",
"default": false,
"public": true,
"conditions": {
"reactions.enabled": true
}
},
{
"id": "reviews_only",
"label": "Reviews only",
"type": "toggle",
"public": true,
"description": "Only enable reactions on reviews and disable them on comments",
"default": false,
"conditions": {
"comments.enabled": true
}
}
]
}
{
"label": "Request for reviews",
"description": "Configure how requests for reviews are sent after orders are placed",
"fields": [
{
"type": "field_row",
"fields": [
{
"id": "notify_after_order_days",
"label": "Days after purchase",
"type": "integer",
"description": "Number of days after a purchase before sending a review request",
"default": 7,
"admin_span": 1
},
{
"id": "storefront_url",
"label": "Storefront URL to submit reviews",
"type": "integer",
"description": "Optionally use a placeholder {{ product_id }} in the URL to link to the product review page",
"admin_span": 3
}
]
}
]
}
{
"label": "Rewards",
"description": "Give reviewers store credit for approved reviews",
"fields": [
{
"id": "enabled",
"label": "Enable rewards",
"type": "toggle",
"default": true
},
{
"type": "field_row",
"fields": [
{
"id": "default_amount",
"label": "Default credit amount",
"type": "currency",
"description": "Default amount to grant to the customer for approved reviews. This can be changed on a per-review basis.",
"localized": true,
"conditions": {
"rewards.enabled": true
}
}
]
}
]
}
{
"label": "Slack notifications",
"description": "Send messages to your internal Slack when reviews are created and approved",
"fields": [
{
"id": "enabled",
"label": "Enable Slack notifications",
"type": "toggle",
"default": false
},
{
"id": "notify_url",
"label": "Review notifications URL",
"type": "text",
"description": "A Slack Incoming Webhook URL to receive review notifications",
"placeholder": "https://hooks.slack.com/services/...",
"conditions": {
"slack.enabled": true
}
}
]
}
{
"label": "Verified buyers",
"description": "Display when a reviwer has purchased the product they are reviewing or commenting on",
"fields": [
{
"id": "enabled",
"label": "Enable verified buyers",
"type": "toggle",
"default": true,
"public": true
}
]
}
To summarize:
- Clear descriptions help communicate the intent of each setting.
- Default values make the initial setup easy and predictable.
- Conditions allow you to hide fields that are only relevant when a feature is enabled.
- Public fields can be retrieved from the storefront API using a public key.
- This can also be used in content and data models, which are detailed later in the guide.
The Honest Reviews app will use serverless functions to respond to platform events and implement the functionality we’ve scoped out.
There are two main use cases in our example.
- Event handlers for the backend
- API endpoints for the frontend
Note: The following example reference app models without a namespace, because functions automatically receive an access token with the permission scope of Honest Reviews. If you were to make the same API calls with a regular secret key, you would need to prefix the endpoints such as /apps/honest_reviews/rewards for example.
Let’s start by creating a function to handle review.created event.
import { notifyReviewCreated } from "./lib/slack";
import { autoReviewActions, updateProductRating } from "./lib/reviews";
export const config: SwellConfig = {
description: "Handle review submission features",
model: {
events: ["review.created"],
},
};
export default async function (req: SwellRequest) {
const settings = await req.swell.settings();
await Promise.all([
autoReviewActions(req, settings).then(() => updateProductRating(req)),
notifyReviewCreated(req, settings),
]);
}
The function is called asynchronously when the event is triggered, similar to a webhook. Notice how it retrieves app settings with await req.swell.settings(), which are then passed to library methods as needed.
Since we have a number of functions that operate on reviews, we’re going to organize common methods in a sub-folder called functions/lib/. Any top-level JS or TS file in the functions/ folder will get treated as its own serverless function, and any file in a sub-folder such as lib/ will be ignored, while referenced code will be bundled with the function at compile time.
Note: TypeScript bindings are available with the package @swell/app-types.
Let’s look at the autoReviewActions method.
import { autoApproveActions } from './approval';
export async function autoReviewActions(req: SwellRequest, settings) {
return autoApproveActions(req, 'reviews', req.data, req.data, settings);
}
Since auto-approval is used for both reviews and comments, we have a shared method named autoApproveActions.
export async function autoApproveActions(
req: SwellRequest,
model: string,
record: SwellData,
review: SwellData,
settings: SwellSettings
) {
const { swell } = req;
const updates = {} as { approved: boolean; verified_buyer: boolean };
// Auto approval
if (settings.approval?.enabled) {
const autoApproveMinOrders = settings.approval?.auto_approve_min_orders || 0;
// Only approve if customer has placed at least X orders
if (autoApproveMinOrders > 0) {
const orderCount = await swell.get('/orders/:count', {
account_id: record.account_id,
canceled: { $ne: true },
});
if (orderCount >= autoApproveMinOrders) {
updates.approved = true;
}
}
}
// Verified buyer
if (settings.verified?.enabled) {
// If customer has purchased the reviewed product
const productOrder = await swell.get('/orders/:last', {
account_id: record.account_id,
items: {
$elemMatch: {
product_id: review.product_id,
},
},
canceled: { $ne: true },
fields: 'id',
});
if (productOrder) {
updates.verified_buyer = true;
}
}
// Update the record
if (Object.keys(updates).length > 0) {
await swell.put('/{model}/{id}', {
model,
id: review.id,
...updates,
});
}
}
The autoApproveActions method does the following.
- If auto-approval is enabled with settings.approval.enabled
- If the minimum number of orders to approve is greater than 0
- Count non-canceled orders by the submitter
- Approve if the order requirement meets criteria
- If the minimum number of orders to approve is greater than 0
- If verifier buyer badges are enabled with settings.verfieid.enabled
- Find an order of the reviewed product by the submitter
- If found, add the verified flag
- Find an order of the reviewed product by the submitter
- Finally, update the review/comment record
Note: When developing or debugging a function, you can use console.log|warn|error() to and view the output in store logs.
We take a similar approach to auto-approving comments in functions/create-comment.ts.
import { notifyCommentCreated } from './lib/slack';
import { autoCommentActions } from './lib/comments';
export const config: SwellConfig = {
description: 'Handle review submission features',
model: {
events: ['review.created'],
},
};
export default async function (req: SwellRequest) {
const settings = await req.swell.settings();
await Promise.all([autoCommentActions(req, settings), notifyCommentCreated(req, settings)]);
}
Now let’s look at the updateProductRating method which is also called by the review-created function.
export async function updateProductRating(req: SwellRequest) {
const { swell, data: review } = req;
const { rating, count } = await getProductReviewRating(req, {
product_id: review.product_id,
status: 'approved',
});
await swell.put('/products/{id}', {
id: review.product_id,
$app: {
[req.appId]: { rating, review_count: count },
},
});
}
export async function getProductReviewRating(req: SwellRequest, query) {
const { swell } = req;
const result = await swell.get('/reviews/:group', {
where: query,
count: {
$sum: 1,
},
rating_total: {
$sum: 'rating',
},
});
return {
count: result.count || 0,
rating: result.count ? result.rating_total / result.count : 0,
};
}
This method runs an aggregation query to get a count and sum of all reviews for the product. It then updates the app fields rating and review_count in the namespace $app.honest_reviews. We’re using the request property req.appId here so that you wouldn’t have to hard-code the app ID, in-case you wanted to change it later.
Now let’s look at the notifyReviewCreated method to see how Slack notifications work.
export async function notifyReviewCreated(req: SwellRequest, settings) {
if (!settings.slack?.enabled) return;
const { store, data: review } = req;
const webhookUrl = settings.slack?.notify_url;
const manageLink = `<${store.admin_url}/collections/app_${req.appId}_reviews/${review.id}|Manage review>`;
const displayNameLink = await getCustomerDisplayNameLink(
req,
review.account_id
);
const message = {
text: `Review submitted by ${displayNameLink} titled "${review.title}" (${review.rating} star). ${manageLink}`,
};
await sendSlackMessage(webhookUrl, message);
}
export async function getCustomerDisplayNameLink(req: SwellRequest, accountId: string) {
const { store, swell } = req;
const account = await swell.get("/accounts/{id}", {
id: accountId,
$content: true,
$app: req.appId,
});
if (!account) {
console.error("Account not found", accountId);
return;
}
const displayName = account?.$app[req.appId]?.name || "Unnamed";
return `<${store.admin_url}/customers/${accountId}|${displayName}>`;
}
export async function sendSlackMessage(webhookUrl: string, message: { text: string }) {
try {
await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
} catch (err) {
console.error("Error sending Slack message", err);
}
}
This method uses settings to determine if the feature is enabled first, and then continues to format the message for slack. It also uses the req.store object to get a link to the Swell admin dashboard. The store request property contains additional information about the store as well.
Next, let’s create a function to serve as an API endpoint for submitting reviews from the frontend. Although it’s possible to configure a model to do this using Swell’s frontend API alone, we have more control and flexibility with validation using a function.
export const config: SwellConfig = {
description: "Submit a review from a storefront",
route: {
public: true
},
};
export default async function post(req: SwellRequest) {
const { swell, data, session } = req;
if (!session?.account_id) {
throw new SwellError("You must be logged in to submit a review", { status: 401 });
}
return await swell.post("/reviews", {
account_id: session.account_id,
product_id: data.product_id,
title: data.title,
body: data.body,
rating: data.rating,
});
}
The function is called by the frontend with a URL in the following format:
POST https://<store_id>.swell.store/functions/honest_reviews/create-review
The endpoint accepts an HTTP header X-Session representing the current user session, created by Swell’s frontend API using Swell.js. The SDK has a helper method for calling functions, so we don’t have to implement it from scratch.
await swell.functions.post('honest_reviews', 'create-review', {
product_id,
title,
body,
rating,
})
We have a similar approach to creating comments and reactions from the frontend.
export const config: SwellConfig = {
description: "Submit a comment from a storefront",
route: {
public: true,
},
};
export async function post(req: SwellRequest) {
const { swell, data, session } = req;
if (!session?.account_id) {
throw new SwellError("You must be logged in to submit a comment", {
status: 401,
});
}
await swell.post("/reviews:comments", {
account_id: session.account_id,
parent_id: data.review_id,
title: data.title,
body: data.body,
});
}
export const config: SwellConfig = {
description: "Create or update a reaction from a storefront",
route: {
public: true,
},
};
export async function post(req: SwellRequest) {
const { swell, data, session } = req;
if (!session?.account_id) {
throw new SwellError("You must be logged in to submit a reaction", {
status: 401,
});
}
// Update or delete existing reaction
if (data.id) {
if (data.liked !== undefined) {
return swell.put("/reviews:reactions/{id}", {
id: data.id,
account_id: session.account_id,
parent_id: data.review_id,
liked: data.liked,
});
}
return swell.delete("/reviews:reactions/{id}", {
id: data.id,
});
}
// Create new reaction
return swell.post("/reviews:reactions", {
account_id: session.account_id,
parent_id: data.review_id,
liked: data.liked,
});
}
Next, let’s look at how ratings are updated and rewards are granted when a review is approved.
The following function uses a new event called review.approved, so before this function can be pushed, we need to define new app events on the Reviews model. For good measure, we’ll add events to capture the approval stages for both reviews and their comments.
{
...
"comments": {
...
"events": {
"types": [
{ "id": "created" },
{ "id": "updated" },
{ "id": "deleted" },
{
"id": "submitted",
"conditions": {
"$formula": "and($record.status != 'submitted', status == 'submitted')"
}
},
{
"id": "approved",
"conditions": {
"$formula": "and($record.status != 'approved', status == 'approved')"
}
},
{
"id": "rejected",
"conditions": {
"$formula": "and($record.status != 'rejected', status == 'rejected')"
}
}
]
}
}
},
...
"events": {
"types": [
{ "id": "created" },
{ "id": "updated" },
{ "id": "deleted" },
{
"id": "submitted",
"conditions": {
"$formula": "and($record.status != 'submitted', status == 'submitted')"
}
},
{
"id": "approved",
"conditions": {
"$formula": "and($record.status != 'approved', status == 'approved')"
}
},
{
"id": "rejected",
"conditions": {
"$formula": "and($record.status != 'rejected', status == 'rejected')"
}
}
]
}
}
Since we need to compare the existing record state with the new status value, the simplest solution is to use conditions.$formula and a special value that refers to the pre-existing record before the update, $record.
Note that we also need to include the created, updated, and deleted events in the configuration, otherwise the platform would not trigger those events. They are defined by default on a model unless you set events.enabled: false, but when adding app-specific events, it’s important to maintain their presence in the model.
Now we can create the function to run when a review is approved.
import { updateProductRating } from "./lib/reviews";
import { createReviewReward } from "./lib/rewards";
export const config: SwellConfig = {
description: "Handle review approval features",
model: {
events: ["review.approved"],
},
};
export default async function (req: SwellRequest) {
const settings = await req.swell.settings();
await Promise.all([
updateProductRating(req),
createReviewReward(req, settings),
]);
}
The function calls the createReviewReward method.
export async function createReviewReward(req: SwellRequest, settings) {
if (!settings.rewards?.enabled) return;
const { swell, data: review } = req;
// Reward can be manually disabled
if (review.reward_disabled) {
return;
}
// Create a reward credit for an approved review
const reward = await swell.post("/rewards", {
review_id: review.id,
account_id: review.account_id,
amount:
review.$locale?.reward_amount ||
review.reward_amount ||
settings.rewards?.default_amount,
});
if (reward.errors) {
console.error("Error creating reward", review.errors);
}
}
The method checks if the rewards feature is enabled by settings, and then creates a reward record. The account credit is then handled by the reward-created function.
import { createRewardCredit } from "./lib/rewards";
import { notifyRewardCreated } from "./lib/slack";
export const config: SwellConfig = {
description: "Create a reward for an approved review",
model: {
events: ["reward.created"],
},
};
export default async function (req: SwellRequest) {
const settings = await req.swell.settings();
await Promise.all([
createRewardCredit(req, settings),
notifyRewardCreated(req, settings),
]);
}
export async function createRewardCredit(req: SwellRequest, settings) {
if (!settings.rewards?.enabled) return;
const { swell, data: reward } = req;
// Create an account credit based on the reward
await swell.post("/accounts:credits", {
parent_id: reward.account_id,
amount: reward.$locale?.amount || reward.amount,
reason: "promo",
reason_message: `Reward for an honest review`,
$app: {
honest_reviews: { review_id: reward.review_id, reward_id: reward.id },
},
});
await swell.put("/reviews/{id}", {
id: reward.review_id,
rewarded: true,
});
}
Finally, let’s look at how we score reviews and comments based on reactions.
import { calculateReactionScores } from "./lib/reactions.js";
export const config: SwellConfig = {
description: "Apply scores to reviews and comments based on reactions",
model: {
events: [
"review.reaction.created",
"review.reaction.updated",
"review.reaction.deleted",
],
},
};
export default async function (req: SwellRequest) {
const settings = await req.swell.settings();
await calculateReactionScores(req, settings);
}
The function is run when a reaction is created, updated, or deleted, and then calls the calculateReactionScores method to do the heavy lifting.
export async function calculateReactionScores(req: SwellRequest, settings) {
if (!settings.reactions?.enabled) return;
const { swell, data: reaction } = req;
const { likes_only, reviews_only } = settings?.reactions || {};
const review = await swell.get("/reviews/{id}", {
id: reaction.parent_id,
expand: "account",
});
if (!review) {
console.error("Review not found", reaction);
return;
}
// Get all reaction totals based on settings
const [reviewLikes, reviewDislikes, commentLikes, commentDislikes] =
await Promise.all([
// Reviews
reaction.liked
? getReactionLikes(req, { parent_id: reaction.parent_id, liked: true })
: null,
!reaction.liked && !likes_only
? getReactionLikes(req, { parent_id: reaction.parent_id, liked: false })
: null,
// Comments
reaction.liked && !reviews_only
? getReactionLikes(req, {
comment_id: reaction.comment_id,
liked: true,
})
: null,
!reaction.liked && !reviews_only
? getReactionLikes(req, {
comment_id: reaction.comment_id,
liked: false,
})
: null,
]);
// Update all relevant reaction counts and scores
await Promise.all([
// Reviews
reviewLikes?.value !== undefined &&
swell.put("/reviews/{id}", {
id: reaction.parent_id,
like_count: reviewLikes.value,
}),
reviewDislikes?.value !== undefined &&
swell.put("/reviews/{id}", {
id: reaction.parent_id,
dislike_count: reviewDislikes.value,
}),
// Comments
commentLikes?.value !== undefined &&
swell.put("/reviews:comments/{id}", {
id: reaction.comment_id,
like_count: commentLikes.value,
}),
commentDislikes?.value !== undefined &&
swell.put("/reviews:comments/{id}", {
id: reaction.comment_id,
dislike_count: commentDislikes.value,
}),
// Reviewer score
swell.put("/accounts/{id}", {
id: review.account_id,
$app: {
[req.appId]: {
score:
(review.account?.$app?.[req.appId]?.score || 0) + (reaction.liked
? 1
: -1),
},
},
}),
]);
}
export async function getReactionLikes(req: SwellRequest, query) {
const { swell } = req;
return await swell.get("/reviews:reactions/:group", {
where: query,
value: {
$sum: 1,
},
});
}
The method uses an aggregation query to get the sum of all likes and dislikes for the review, comment, and account that the reaction was intended for. It then updates the corresponding records with a total score. The score is used to sort reviews and comments in the frontend.
It’s common for an app to send email notifications when things happen. As a core part of the Swell app platform, we can easily configure these notifications including their HTML content. The merchant has the ability to modify the content, and to enable or disable the notifications at any time.
Let’s create a notification to the customer when a review is submitted.
{
"collection": "reviews",
"label": "Review submitted",
"subject": "Thank you for submitting a review of {{ product.name }}",
"description": "Notify customer when a review is submitted",
"method": "email",
"contact": "account.email",
"event": "submitted",
"query": {
"expand": ["account", "product"]
},
"repeat": true,
"sample": {
"rating": 5,
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
},
"product_url": "https://example.com/products/product-one",
"reward_amount": 5
}
}
To summarize:
- Subject including the name of the product reviewed
- Sent to the customer email referenced by the review
- Triggered by the submitted event on the review collection
- Expands account and product reference for use in the email template
- Sample data for the merchant to preview the template in the dashboard
Next, let’s create the notification email template. The name of the template file must match the name of the configuration file, review-submitted.
Hi {{ account.first_name }},
Your {{ rating }} star review for <a href="{{ product_url }}">{{ product.name }}</a> was submitted and will be reviewed by our staff.
{%- if settings.rewards.enabled -%}
When your review is shared with the community, you'll receive a special gift from us! We'll send you {{ reward_amount | currency }} in store credit.
{%- endif -%}
Thank you for your business and we look forward to seeing you again.
The template uses a version of Liquid, and references settings to determine whether to show the reward offer or not. Note that the templates in this app are very minimal, as typically you would use a more complete HTML wrapper fit for common email clients.
Now let’s create a notification for when a review is approved.
{
"collection": "reviews",
"label": "Review approved",
"subject": "Your review of {{ product.name }} was published",
"description": "Notify customer when a review is approved",
"method": "email",
"contact": "account.email",
"event": "approved",
"query": {
"expand": ["account", "product"]
},
"repeat": true,
"sample": {
"rating": 5,
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
},
"product_url": "https://example.com/products/product-one",
"reward_amount": 5
}
}
Hi {{ account.first_name }},
Your {{ rating }} star review for <a href="{{ product_url }}">{{ product.name }}</a> was published!
{%- if settings.rewards.enabled and reward_amount > 0 -%}
To show our thanks, we've added {{ reward_amount | currency }} to your account to spend on future purchases.
{%- endif -%}
Thank you for your business and we look forward to seeing you again.
This configuration is similar, with the addition of the repeat: true property, which tells the platform that the notification should be sent any time the review.approved event occurs. Without this property, the email would only be sent once the first time the event is triggered.
With the basics covered, let’s add notifications to represent the remaining review features.
- Review rejection
- Comment approval and rejection
- Review requests after a product is purchases
{
"collection": "reviews",
"label": "Review rejected",
"subject": "Your review of {{ product.name }} was not approved",
"description": "Notify customer when a review is rejected",
"method": "email",
"contact": "account.email",
"event": "rejected",
"query": {
"expand": ["account", "product"]
},
"repeat": true,
"sample": {
"rating": 5,
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"rejected_reason": "Your review is not relevant to this product.",
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
},
"product_url": "https://example.com/products/product-one"
}
}
Hi {{ account.first_name }},
Your {{ rating }} star review for <a href="{{ product_url }}">{{ product.name }}</a> was not approved for the following reason:
{{ rejected_reason }}
--
{{ body }}
--
If you have any feedback about this decision, we welcome your feedback. You can reply to this email.
Thank you
{
"collection": "reviews:comments",
"label": "Comment approved",
"subject": "Your comment on a review was published",
"description": "Notify customer when a comment is approved",
"method": "email",
"contact": "account.email",
"event": "approved",
"query": {
"expand": ["account", "parent.product"]
},
"repeat": true,
"sample": {
"rating": 5,
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"body": "This review was so helpful, thank you!",
"parent": {
"title": "Works like a charm",
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
}
},
"product_url": "https://example.com/products/product-one"
}
}
Hi {{ account.first_name }},
Your comment on a review for <a href="{{ product_url }}">{{ parent.product.name }}</a> titled "{{ parent.title }}" was published:
--
{{ body }}
--
Thank you for taking part in our community.
{
"collection": "reviews:comments",
"label": "Comment rejected",
"subject": "Your comment on a review was not approved",
"description": "Notify customer when a comment is rejected",
"method": "email",
"contact": "account.email",
"event": "rejected",
"query": {
"expand": ["account", "parent.product"]
},
"repeat": true,
"sample": {
"rating": 5,
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"body": "My order was late",
"rejected_reason": "Your comment is not relevant to the review.",
"parent": {
"title": "Works like a charm",
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
}
},
"product_url": "https://example.com/products/product-one"
}
}
Hi {{ account.first_name }},
Your comment on a review titled "{{ parent.title }}" was rejected for the following reason:
{{ rejected_reason }}
--
{{ body }}
--
If you have any feedback about this decision, we welcome your feedback. You can reply to this email.
Thank you
{
"collection": "reviews",
"label": "Review approved",
"subject": "Let us know what you think of {{ product.name }} {% if $settings.rewards.enabled %}and get a special gift{% endif %}",
"description": "Ask a customer review their purchase",
"method": "email",
"contact": "account.email",
"event": "approved",
"repeat": true,
"sample": {
"account": {
"name": "Jane Doe",
"first_name": "Jane",
"last_name": "Doe"
},
"product": {
"name": "Product One",
"images": [
{
"file": {
"url": "https://cdn.swell.store/default-theme/5f63e175e7eed80c11766c83/886a77f13bc9e09396f7a378edb170af"
}
}
]
},
"product_url": "https://example.com/products/product-one",
"review_url": "https://example.com/products/product-one#write-review",
"reward_amount": 5
}
}
Hi {{ account.first_name }},
You recently purchased <a href="{{ product_url }}">{{ product.name }}</a> and we'd love to hear your feedback.
Please take a moment to review your purchase here: <a href="{{ review_url }}">{{ review_url }}</a>
{%- if settings.rewards.enabled -%}
When your review is shared with the community, you'll receive a special gift from us!
We'll send you {{ reward_amount | currency }} in store credit.
{%- endif -%}
Thank you and we look forward to hearing about your purchase.
With all of the app configurations in place, it’s time to explore how Honest Reviews works in the frontend. The storefront app is built with Next.js 14 and uses Swell.js to work with store data. It’s a simple example that covers a wide range of topics in a small code base.
This guide won’t go through all the React code, but we’ll highlight the key interactions with Swell and the Honest Review models.
This Next.js app was developed before the introduction of app frontends and should not serve as a canonical example of a storefront app for that reason. It may not be deployable as a frontend on Swell, however may be separately deployed on Vercel for example.
In order to support the popular practice of building frontend websites (JAMstack) that interact with public APIs, Swell has introduced a new way to configure how model fields should be made accessible in a public interface.
Looking back to our Reviews model, let’s add a couple of properties to define how it should be accessed via the Swell frontend API.
"public": true,
"public_permissions": {
"query": {
"sort": "score desc",
"where": {
"status": "approved"
}
}
},
"fields": {
...
"comments": {
...
"public_permissions": {
"query": {
"sort": "score desc",
"where": {
"status": "approved"
}
}
},
...
}
...
}
...
}
The addition of public: true declares that the entire collection and all of its fields are public by default. Alternately if we wanted to publicize only a few fields, we could set public: true on individual model fields. Note that the public property can also be set on content models, to the same effect.
The addition of public_permissions allows us to refine the permissions, in this case declaring that only approved reviews can be retrieved by the frontend API. Also, we declare that the collection should be sorted by score in descending order when queried this way.
We’re applying the same public permissions to Comments, which inherits the public status of the parent Reviews model.
Note: Public permission properties can be applied to standard and custom models in any store.
All data fetching will happen on the client side, enabled by public permissions.
First, the app initializes the swell client in storefront/app/swell/index.ts.
swell.init(STORE_ID, PUBLIC_KEY);
When the homepage loads, it calls the useProductList hook.
export function useProductList() {
const [products, setProducts] = useState<any>(null);
useEffect(() => {
swell.products.list().then((products) => {
setProducts(products);
});
}, []);
return products;
}
In a full-blown application, we would probably load categories, a list of featured products, consider pagination, but in our example we’re keeping it simple.
The homepage then retrieves featured reviews with useFeaturedReviews.
export function useFeaturedReviews(query: SwellData = {}) {
const { account, accountReady, appSettings, appSettingsReady } = useSwellContext();
const [reviews, setReviews] = useState<any>();
useEffect(() => {
if (!accountReady || !appSettingsReady) return;
if (!appSettings?.featured?.enabled) return;
if (query?.product_id !== undefined && !query.product_id) return;
getReviews(appSettings, account, {
featured: true,
limit: appSettings?.featured?.display_count || 3,
...query,
}).then((reviews) => {
setReviews(reviews);
});
}, [accountReady, appSettingsReady, account?.id, JSON.stringify(query)]);
return reviews;
}
async function getReviews(
appSettings: SwellData | null,
account?: Account | null,
query: SwellData = {}
) {
return await swell.get('/reviews', {
limit: 10,
...query,
expand: 'account, comments.account',
sort: 'score desc',
include: {
reaction: account &&
appSettings?.reactions?.enabled && {
url: '/reviews:reactions/:last',
params: {
parent_id: 'id',
},
data: {
account_id: account.id,
fields: 'id, liked',
sort: 'score desc',
},
},
},
});
}
The useFeaturedReviews hook does the following.
- Get customer account and app settings from context
- Wait until account and app settings are loaded
- If featured reviews are not enabled, return nothing
- If fetching featured reviews for a specific product but no ID was passed, return nothing
- Call getReviews with the setting featured.display_count
- Swell.js used to get /reviews (accessible via public permissions)
- Expand account and comments with account to display
- Include a reaction of the current logged-in user, if reactions are enabled
- Swell.js used to get /reviews (accessible via public permissions)
Note: You could limit the fields in the response further is desired, using the fields parameter, but for simplicity we will get the full public field set.
The shape of the response will look like the following.
{
count: 3,
results: [
{
id,
title,
body,
rating,
date_created,
account: { ... },
comments: {
count: 1,
results: [
{
body,
date_created,
account: { ... },
...
}
]
}
reaction?: {
id,
liked
},
...
},
...
],
...
}
Next, the product detail page calls the useProductDetail hook.
export function useProductDetail(productId: string) {
const { account, accountReady, appSettings, appSettingsReady } = useSwellContext();
const [product, setProduct] = useState<any>(null);
useEffect(() => {
if (!productId) return;
if (!accountReady || !appSettingsReady) return;
swell.products
.get(productId, {
include: {
reviews: {
url: '/reviews',
params: {
product_id: 'id',
},
data: {
expand: 'account, comments.account',
include: {
reaction: account &&
appSettings?.reactions?.enabled && {
url: '/reviews:reactions/:last',
params: {
parent_id: 'id',
},
data: {
account_id: account.id,
fields: 'id, liked',
sort: 'score desc',
},
},
},
},
},
},
})
.then((product) => {
setProduct(product);
});
}, [productId, accountReady, appSettingsReady]);
return product;
}
The useProductDetail hook is sort of a combination of useProductList and useProductReviews as it retrieves a single product and includes reviews along with the product record. This allows us to retrieve all the data in a single request to improve performance.
It includes the current logged-in account reaction if the setting is enabled.
Again, we could be doing pagination and search in the same query, or another query, but for simplicity we are only fetching up the default number of reviews (25) sorted by score.
Next, the product detail page has a sub-component with a form to submit reviews. This form makes a call to create a review when submitted.
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await callAppFunction('post', 'create-review', {
...formData,
product_id: product.id,
});
window.location.href = '/review-submitted';
} catch (err: any) {
console.log(err);
alert(err.message);
}
};
The onSubmit event handler uses the callAppFunction method from storefront/app/swell/index.ts.
export async function callAppFunction(method: string, functionName: string, data: any) {
try {
return swell.functions.request(method, APP_ID, functionName, data);
} catch (err: any) {
console.error(err);
alert(err.message);
}
}
This method is a simple wrapper for swell.functions.request, which is used to make a request directly to the create-review app function.
As a reminder, let’s look at the create-review function in functions/create-review.ts.
export const config: SwellConfig = {
description: "Submit a review from a storefront",
route: {
public: true
},
};
export default async function post(req: SwellRequest) {
const { swell, data, session } = req;
if (!session?.account_id) {
throw new SwellError("You must be logged in to submit a review", { status: 401 });
}
return await swell.post('/reviews', {
account_id: session.account_id,
product_id: data.product_id,
title: data.title,
body: data.body,
rating: data.rating,
});
}
Remember that the swell.functions methods automatically pass the session of the logged-in account, which is then easily accessed and used in the function to create a review.
The same approach is used for submitting comments.
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const result = await callAppFunction('post', 'create-comment', {
...formData,
review_id: review.id,
});
if (!result.errors) {
setNewComment(result);
setShowCommentForm(false);
}
} catch (err: any) {
console.log(err);
alert(err.message);
}
};
Which again uses the callAppFunction method to post a request to a function.
export const config: SwellConfig = {
description: "Submit a comment from a storefront",
route: {
public: true,
},
};
export async function post(req: SwellRequest) {
const { swell, data, session } = req;
if (!session?.account_id) {
throw new SwellError("You must be logged in to submit a comment", {
status: 401,
});
}
return await swell.post("/reviews:comments", {
account_id: session.account_id,
parent_id: data.review_id,
title: data.title,
body: data.body,
});
}
In the comment-form.tsx component, we receive the response and set the newly created comment to a state variable, in order to render a success message “Your comment has been submitted”. This could be improved by showing the comment body, possibly an edit action, or even to show the comment posted immediately if auto approval is configured.
With the basics covered, let’s briefly look at the remaining Swell interactions.
- Login
- Signup
- Global context
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
setLoginFailed(false);
const account = await swell.account.login(formData.email, formData.password);
if (account) {
setAccount(account);
window.location.href = '/';
} else {
setLoginFailed(true);
}
} catch (err: any) {
console.log(err);
alert(err.message);
}
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSignupFailed(true);
const account = (await swell.account.create(formData)) as any;
if (account?.errors as any) {
alert(JSON.stringify(account.errors));
} else if (account) {
setAccount(account);
window.location.href = '/';
} else {
setSignupFailed(true);
}
};
export function useAccount() {
const [account, setAccount] = useState<any>(null);
const [accountReady, setAccountReady] = useState(false);
useEffect(() => {
swell.account.get().then((account) => {
setAccount(account);
setAccountReady(true);
});
}, []);
return { account, accountReady, setAccount };
}
export function useSwellSettings() {
const [swellSettings, setSwellSettings] = useState<any>(null);
const [swellSettingsReady, setSwellSettingsReady] = useState(false);
useEffect(() => {
swell.settings.get().then((swellSettings) => {
setSwellSettings(swellSettings);
setSwellSettingsReady(true);
});
}, []);
return {
swellSettings,
swellSettingsReady,
};
}
export function useAppSettings() {
const [appSettings, setAppSettings] = useState<any>(null);
const [appSettingsReady, setAppSettingsReady] = useState(false);
useEffect(() => {
swell.get(`/settings/${APP_ID}`).then((appSettings) => {
setAppSettings(appSettings);
setAppSettingsReady(true);
});
}, []);
return {
appSettings,
appSettingsReady,
};
}
export function SwellProvider({ children }: { children: any }) {
const { account, accountReady, setAccount } = useAccount();
const { swellSettings, swellSettingsReady } = useSwellSettings();
const { appSettings, appSettingsReady } = useAppSettings();
return (
<SwellContext.Provider
value={{
account,
accountReady,
setAccount,
swellSettings,
swellSettingsReady,
appSettings,
appSettingsReady,
}}
>
{children}
</SwellContext.Provider>
);
}
The global context provider in this React app is fairly typical, but useful to point out how we’re retrieving the logged-in account, Swell settings, and Honest Review app settings before any deeper interactions are made.
Retrieve the current logged in account with swell.account.get(). Note that the result will succeed with null if the account is not currently logged-in.
export function useAccount() {
const [account, setAccount] = useState<any>(null);
const [accountReady, setAccountReady] = useState(false);
useEffect(() => {
swell.account.get().then((account) => {
setAccount(account);
setAccountReady(true);
});
}, []);
return { account, accountReady, setAccount };
}
Retrieve Swell store settings with swell.settings.get(). The response includes a store object with details about the configured currencies, locales, store name, and more.
export function useSwellSettings() {
const [swellSettings, setSwellSettings] = useState<any>(null);
const [swellSettingsReady, setSwellSettingsReady] = useState(false);
useEffect(() => {
swell.settings.get().then((swellSettings) => {
setSwellSettings(swellSettings);
setSwellSettingsReady(true);
});
}, []);
return {
swellSettings,
swellSettingsReady,
};
}
Finally, retrieve the Honest Review app settings with swell.get(/settings/${APP_ID}). The variable APP_ID can be configured in .env.local and defaults to honest_reviews.
Basic methods swell.get, swell.put, swell.post, and swell.delete are available to interact for app-defined or custom collections. The /settings collection represents all of the store’s settings, and the app settings record specifically can be retrieved using a URL segment matching the app ID itself.
That’s it for our walkthrough of the Honest Reviews app. We’re excited to partner with you and build a future of ecommerce together!
The Honest Reviews app is meant as an example for partners to learn and build from, but it is not ready for production. We can’t offer support or bug fixes at this time, however with an MIT license, you’re free free to clone it and build your own.
Our app is fairly comprehensive, but we left out many features for the sake of time. Here’s a list of what we didn’t get around to. Since the app is open-source, contributions are welcome.
Backend
- Rewards management in the dashboard
- Email notification templates in HTML layout
Frontend
- Account page to manage reviewer profile
- Show user pending reviews/comments
- Display reviewer photos
- Review images upload
- Verified buyer badges