I built this prototype to help restaurant avoid handling physical menus to their clients during the COVID-19 pandemic situation which would need to be sanitised afterwards.
QR codes that redirects to the online PDF menu can be printed once as they never change and clients can scan them with their smartphones while sitting at the table.
NB: I was targeting the Italian market so the UI is all in Italian.
First steps
Bought the domain https://menu-qr.tech/ from Vercel (formerly Zeit) where I could easily manage DNS and connect the frontend repo for automatic deployments
Created a new project on Heroku where I got a node dyno and a Postgres database connection, all on the free tier.
Created a bucket on AWS S3 named
view.menu-qr.tech
, configured it to be public accessible as that's where I would upload the menus and put Cloudfront in front of it to have a CDN for faster loads. I've also added the relevant DNS CNAME records to Vercel in order to associate the subdomain with the Cloudfront distribution.I initially thought about adding Stripe for paid subscriptions so I've registered, got my development key and verified myself.
Registered a new project with Auth0 to handle the passwordless authentication.
Registered and connected the domain with Mailgun in order to send transactional and authentication emails.
How does it work?
The user once authenticated can upload a menu, at this point a few things happen:
- the PDF menu is uploaded on S3, I put a timestamp on it in order to avoid overwriting existing menus as I want them to be immutable but still keep track of the file name as it can be handy.
- a new
Upload
entry is created in the DB, generating a UUID and saving the S3 url and the path where the file is located plus other info. - a QR code is generated on demand, pointing at the url
https://view.menu-qr.tech/?id={{UUID}}
that will never change for this menu
At that point a customer can scan that QR code which will point to the view.menu-qr.tech/?id={{UUID}}
page that will show a loading spinner and make a GET request to the API to fetch the correct URL where the PDF menu can be viewed, using the Cloudfront CDN url rather than S3.
The restaurant owner can go and update the menu anytime on the dashboard, making a new upload that will update the S3 url reference on the DB, allowing the final customer to view the updated menu still using the old QR code (no need to print it again).
The project involved 3 repos:
Web app (https://menu-qr.tech/)
It's a SPA built with
create-react-app
, using:
- Auth0 to handle passwordless authentication
- Rebass for the UI primitives with a custom basic theme.
-
SWR for data fetching
Once the user is logged in they can see their dashboard where they can create a restaurant and upload a menu.
I connected this repo to Vercel so every time I pushed the code to
master
it automatically built and deployed the latest version. I usedreact-icons
and https://undraw.co/illustrations to make it nicer.
Server (https://api.menu-qr.tech/)
Built with node using express, where I defined all the routes for CRUD operations, persisting data on a Postgres database using Sequelize as ORM to be quicker.
The server is also handling all the image uploading to S3 using
multer
, here is a snippet of how it's doneconst fileSize = 1024 * 1024 * 5; // 5mb
const upload = multer({
limits: {
fileSize,
},
fileFilter: (req, file, callback) => {
const ext = path.extname(file.originalname);
if (ext !== '.png' && ext !== '.jpg' && ext !== '.pdf' && ext !== '.jpeg') {
callback(new Error('Only PDF or images'));
return;
}
callback(null, true);
},
storage: multerS3({
s3,
bucket: 'view.menu-qr.tech',
acl: 'public-read',
contentType: multerS3.AUTO_CONTENT_TYPE,
key: (req, file, cb) => {
// append timestamp to avoid overwriting
cb(null, `${file.originalname}_${Date.now()}`);
},
}),
});
I like Sequelize as it can make your life easier in these small projects, here is where I defined the tables and associations
const db = {
Sequelize,
sequelizeInstance,
User: sequelizeInstance.import('./User.js'),
Restaurant: sequelizeInstance.import('./Restaurant.js'),
Upload: sequelizeInstance.import('./Upload.js'),
};
db.User.hasMany(db.Restaurant);
db.Restaurant.belongsTo(db.User);
db.Restaurant.hasMany(db.Upload);
db.Upload.belongsTo(db.Restaurant);
module.exports = db;
Then you can easily load a user restaurant's and the their uploads
const data = await db.User.findByPk(userId, {
include: [
{
model: db.Restaurant,
include: db.Upload,
},
],
});
I've used qrcode
package to generate QR codes on demand which is nice because it supports streams, no need to save/read data on the disk.
app.get('/view-qr/:uploadId', async (req, res) => {
const { uploadId } = req.params;
const url = `https://view.menu-qr.tech/?id=${uploadId}`;
QRCode.toFileStream(res, url, {
width: 512,
margin: 0,
color: {
dark: '#000',
light: '#fff',
},
});
});
There is already Stripe built in supporting subscriptions management and handling webhooks for client-side checkout events, and also the logic to give users a trial period and expire with cron jobs.
Menu loader page (https://view.menu-qr.tech/)
This is a simple index.html
page that is used to show a spinner and redirect the user to the menu or show an error message.
It's being deployed at https://view.menu-qr.tech/?id=
automatically with Vercel, here is the simple configuration and the page code.
vercel.json
{
"version": 2,
"routes": [{ "src": "/(.*)", "dest": "/index.html" }]
}
index.html
<html lang="en">
<title>Caricamento</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/css-spinning-spinners/1.1.1/load8.css"
/>
<style>
html,
body {
font-family: sans-serif;
}
</style>
<body>
<div id="root" style="padding: 24px; text-align: center;">
<div class="loading" />
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('id');
fetch(`https://api.menu-qr.tech/view/${id}`)
.then((res) => {
if (res.status === 403) {
document.getElementById('root').innerHTML = 'Subscription expired';
return;
}
if (res.ok) {
res.json().then((json) => window.location.replace(json.url));
return;
}
throw new Error('fail');
})
.catch(
() =>
(document.getElementById('root').innerHTML = 'Error loading'),
);
</script>
</body>
</html>
Right after building this I realised there are already solutions that are more complete and supported by existing companies so I decided to stop the project and open-source it.
It was a good exercise and I hope it can be useful for others.
Thanks for reading 😀
Top comments (0)