Hello,
Here is the Hugo code, ready to be used for a simple online shop with only Netlify & Stripe.
I followed a lot of thread about creating an online shop with Hugo, but nothing really was simple or ready or they were using a SAAS shop over a payment process, and the costs for that were too big for small amounts/items.
On this thread, I found recently a great tutorial about creating a small site for selling items, using only Netlify & Stripe. Thanks @alexandros !
It worked fine, but there were some problem for (me &) Hugo:
- The shop was fully done in Javascript
- It was not multi-language
- Shipping countries allowed was just 2
- The images were âstaticâ images, not âassetsâ because of the way Stripe works,
- You had to give the full real http:// url in the products.json,
- Only one image for items,
- No lazy loading
- etcâŠ
But what they have done was amazing, very didactic and ready to use with only Stripe & Netlify.
So I worked a little, and here it is, based on this great tutorial : a solution for Hugo, ready to be used as is.
And this address all the concerns I listed above.
First : watch their video (30 min) so you understand how this will work, and what you have to do on Netlify (& Stripe).
Then use & adapt my code in your Hugo project.
I tried to comment a lot of code, so it will be easier to understand what part of code is doing what function. And then for you to adapt it to your needs.
Enjoy.
Screenshot
This is a really basic shop, where you can order only one item at a time. The purpose of this was not the UI but the plumbing to connect Hugo/Stripe/Netlify.
File structure
/assets/images/shop/âŠ
/functions/data/products.json
/functions/create-checkout.js
/layouts/partials/shop.html
/static/js/stripe-purchase.js
Items file : /functions/data/product.json
[
{
"sku" : "MAGNET-01",
"name" : "Magnet DĂ©capsuleur",
"description": "Magnet décapsuleur du camping Arolla",
"image": "/images/shop/magnet-logo.jpg",
"image_hover": "/images/shop/magnet-decapsuleur.jpg",
"amount": "860",
"currency": "CHF"
},
{
"sku" : "BUNDLE-01",
"name" : "Pack extra",
"description": "Bundle Magnet + Carte Postale",
"image": "/images/shop/bundle-magnet-carte.jpg",
"amount": "1000",
"currency": "CHF"
},
{
"sku" : "CARTE-01",
"name" : "Carte Postale",
"description": "Carte Postale du camping Arolla",
"image": "/images/shop/carte-recto.jpg",
"image_hover": "/images/shop/carte-verso.jpg",
"amount": "190",
"currency": "CHF"
}
]
partial /layouts/partials/shop.html
This layout uses the bootstrap framework. Adapt the layout to your needs or framework.
<!--Mandatory script pour STRIPE -->
<script src="https://js.stripe.com/v3/"></script>
<!-- Titre du SHOP -->
<section class="section bg-gray" id="shop">
<!-- Shop managed directly by Hugo -->
<div class="container">
<div class="row align-items-end">
<!-- data articles MANDATORY put in /functions/data : needed for create-checkout.js used by Netlify function -->
{{- $count := 0 }}
{{- $dataJSON := getJSON "/functions/data/products.json" }}
{{- range $dataJSON }}
{{- $count = add $count 1 }}
<div class="col-lg-4 col-md-6 col-sm-12 ">
<div class="product">
<!-- Images in ASSETS
Manage switch 2 images by prodcut / with simple mouse over
and adapt same height to avoid flicking -->
{{- $img_original := resources.Get .image -}}
{{- $img := $img_original.Resize "400x400" -}}
{{- $img_hover := "" }}
{{- if .image_hover }}
{{- $img_hover_original := resources.Get .image_hover -}}
{{- $img_hover = $img_hover_original.Resize "400x400" -}}
{{- end }}
{{- $lazy := "" -}}
<img
{{- if site.Params.global.lazyload.enable }}
{{- $lazy = site.Params.global.lazyload.label -}}
{{- $placeholder := $img_original.Resize "3x q20" }}
src="data:image/jpeg;base64,{{ $placeholder.Content | base64Encode }}"
data-src="{{- $img.RelPermalink -}}"
{{- else }}
src="{{- $img.RelPermalink -}}"
{{- end }}
class = "img-fluid shadow rounded {{ $lazy -}}"
alt = "{{ .name }}"
title = "{{- .description -}}"
width = "{{- $img.Width -}}" height = "{{- $img.Height -}}"
{{- if .image_hover }}
onmouseover="this.src='{{ $img_hover.RelPermalink }}'"
onmouseout="this.src='{{ $img.RelPermalink }}'"
{{- end }}
>
<!-- Use this code if you want to use images from STATIC folder
<img src="{{ .image | relURL }}"
{{- if .image_hover }}
onmouseover="this.src='{{ .image_hover }}'"
onmouseout="this.src='{{ .image }}'"
{{- end }}
alt = "{{ .name }}"
/>
-->
<h2>{{ .name }}</h2>
<p class="description">{{ .description}}</p>
<p class="price">{{ i18n "prix-shop" }} : {{ lang.NumFmt 2 (div (float .amount) 100) }} {{ .currency }}</p>
<form action="" method="POST" id="hugoform{{ $count }}">
<label for="quantity">{{ i18n "quantity-shop" }} :</label>
<input type="number" id="quantity" name="quantity" value="1" min="1" max="10" />
<input type="hidden" name="sku" value="{{ .sku }}" />
<!-- For multilanguage sites -->
<input type="hidden" name="formlang" value="{{ $.Page.Lang }}" />
<!-- We manage images on shop & on Stripe checkout
with only ONE image on Hugo
Stripe Checkout need an ABSOLUTE URL reachable from Internet
And we have RELATIVE path in our products.json -->
<!-- Needed if Images are managed from STATIC -->
{{- $stripeImgPath := (strings.TrimRight "/" site.BaseURL) }}
<input type="hidden" name="stripeImgPath" value="{{ $stripeImgPath }}" />
<!-- Needed if Images are managed from ASSET -->
<input type="hidden" name="stripeImg" value="{{ $img.Permalink }}" />
<br>
<button type="submit">{{ i18n "buy-shop" }}</button>
</form>
</div>
</div>
{{- end }}
</div>
</div>
<!-- Script for this shop going to Stripe -->
<script type="module" >
// Manage submit button goinf to Stripe
import { handleFormSubmission } from '/js/stripe-purchase.js';
{{- range seq $count }}
// Manage eventlistener for each button/form
document.getElementById('hugoform{{ $count}}').addEventListener('submit', handleFormSubmission);
{{- $count = sub $count 1 }}
{{- end }}
</script>
</section>
{{ "<!-- End shop section -->" | safeHTML }}
File : /static/js/stripe-purchase.js
// Handle form the Submit button
export async function handleFormSubmission(event) {
event.preventDefault();
// Custom Goal for plausible.io
plausible('ShopOrderClick');
const form = new FormData(event.target);
// Get the mandatory data from the form
// formlang : for multilanguage sites
// stripeImgPath : for local images in STATIC
// stripeImg : for local images in ASSETS
const data = {
sku: form.get('sku'),
quantity: form.get('quantity'),
formlang: form.get('formlang'),
stripeImgPath: form.get(`stripeImgPath`),
stripeImg: form.get(`stripeImg`),
};
// Create a Stripe Checkout
const response = await fetch ('/.netlify/functions/create-checkout', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(data),
}).then((res) => res.json());
// Manage Checkout response & SessionID
const stripe = Stripe(response.publishableKey);
const { error } = await stripe.redirectToCheckout({
sessionId: response.sessionId,
});
if (error) {
console.error(error);
}
}
File : /functions/create-checkout.js
// Stripe secret Key on Netlify ENV Variable
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// The inventory (Only one language at the moment)
const inventory = require('./data/products.json');
exports.handler = async (event) => {
// get the mandatory information for charging the client : sku + quantity
// The following URLs are set according to form language
// Get language (formlang) transmited from the form
// Get Absolute path for images on the Stripe checkout page
const { sku, quantity, formlang, stripeImgPath, stripeImg } = JSON.parse(event.body);
// Find the product
const product = inventory.find((p) => p.sku === sku);
// Sanitize quantity
const validateQuantity = quantity > 0 && quantity < 11 ? quantity : 1;
// Needed because I use :
// defaultContentLanguageInSubdir = false
// defaultContentLanguage = "fr"
function root(formlang) {
let rootUrl;
if (formlang == "fr") {
rootUrl = ``;
} else {
rootUrl = `/`+formlang;
}
return rootUrl;
}
// Get the return URL part for the used language
const rootUrl = root(formlang);
// Create Stripe session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
billing_address_collection: 'auto',
shipping_address_collection: {
// All available countries Worldwide
allowed_countries: ['AC','AD','AE','AF','AG','AI','AL','AM','AO','AQ','AR','AT','AU','AW','AX','AZ','BA','BB','BD','BE','BF','BG','BH','BI','BJ','BL','BM','BN','BO','BQ','BR','BS','BT','BV','BW','BY','BZ','CA','CD','CF','CG','CH','CI','CK','CL','CM','CN','CO','CR','CV','CW','CY','CZ','DE','DJ','DK','DM','DO','DZ','EC','EE','EG','EH','ER','ES','ET','FI','FJ','FK','FO','FR','GA','GB','GD','GE','GF','GG','GH','GI','GL','GM','GN','GP','GQ','GR','GS','GT','GU','GW','GY','HK','HN','HR','HT','HU','ID','IE','IL','IM','IN','IO','IQ','IS','IT','JE','JM','JO','JP','KE','KG','KH','KI','KM','KN','KR','KW','KY','KZ','LA','LB','LC','LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MF','MG','MK','ML','MM','MN','MO','MQ','MR','MS','MT','MU','MV','MW','MX','MY','MZ','NA','NC','NE','NG','NI','NL','NO','NP','NR','NU','NZ','OM','PA','PE','PF','PG','PH','PK','PL','PM','PN','PR','PS','PT','PY','QA','RE','RO','RS','RU','RW','SA','SB','SC','SE','SG','SH','SI','SJ','SK','SL','SM','SN','SO','SR','SS','ST','SV','SX','SZ','TA','TC','TD','TF','TG','TH','TJ','TK','TL','TM','TN','TO','TR','TT','TV','TW','TZ','UA','UG','US','UY','UZ','VA','VC','VE','VG','VN','VU','WF','WS','XK','YE','YT','ZA','ZM','ZW','ZZ']
},
// The real next URL for the web site language
// success_url: `${process.env.URL}`+rootUrl+`/thanks/`,
// cancel_url: `${process.env.URL}`+rootUrl+`/oops/`,
success_url: stripeImgPath+rootUrl+`/thanks/`,
cancel_url: stripeImgPath+rootUrl+`/oops/`,
// Informations about the product
line_items: [
{
name: `[`+product.sku+'] '+product.name ,
description: product.description,
// We need ABSOLUTE path Absolute URL for images used by Stripe
// Use this for Images in STATIC
// images: [stripeImgPath+product.image],
// Use this for Images in ASSET
// images: [stripeImg],
images: [stripeImg],
amount: product.amount,
currency: product.currency,
quantity: validateQuantity,
},
],
});
return {
statusCode: 200,
body: JSON.stringify({
sessionId: session.id,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
}),
};
};
Pages thanks.fr.md
& oops.fr.md
You need to create 2 pages and their related languages.
thanks.md
oops.md
They are used by netlify if it succeed or if it fails or you cancel the order.
And do not forget to add this on the frontmatter so they will not be listed on your sitemap.xml
_build :
list : false
render: true
Example for oops.en.md
---
title: "Your order has not been taken into account"
url: oops
date: 2020-08-27T15:53:27+06:00
draft: false
description : "Your order has not been taken into account"
plausible_custom_goal : "ShopOrderFail"
_build :
list : false
render: true
---
## Your order has not been taken into account
We're sorry, but **the order was canceled** and your order could not complete successfully.
You have not been charged and **your order has not been taken into account**.
Example for thanks.en.md
---
title: "Thank you for your purchase"
url: thanks
date: 2020-08-27T15:53:27+06:00
draft: false
description : "Thank you for your purchase"
plausible_custom_goal : "ShopOrderSuccess"
_build :
list : false
render: true
---
## Your order has been registered
We will process your order as soon as possible.
Thank you and see you soon perhaps, on the Arolla campsite !!
The partial oops_thanks.html
for those 2 pages.
<!-- Généralités de la suite de la commande/erreu stripe -->
<section class="about-2 section bg-gray" id="oops">
<div class="container">
<div class="row">
<div class="col-12">
<div class="text-left">
{{ .Content }}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="text-left">
<a class="btn btn-main" href='{{ "shop/" | relLangURL }}'>
<i class="tf-ion-ios-cart"></i> {{ i18n "back2shop" }} <i class="tf-ion-ios-cart"></i>
</a>
</div>
</div>
</div>
</div>
</section>
style CSS snipplet
/* Pour le shop */
.product input {
border: 1px solid teal;
border-radius: 0.25rem;
font-size: 1.125rem;
line-height: 1.25rem;
padding: 0.2rem;
}
.product button {
background: teal;
border: none;
border-radius: 0.25rem;
color: white;
font-size: 1.1rem;
font-weight: 800;
line-height: 1.1rem;
padding: 0.25rem;
width: 110px;
height: 30px;
margin-bottom: 40px;
}
.product h2 {
font-size: 1.25rem;
}
.product .price {
font-weight: 800;
}
.product .description {
font-style: italic;
}