How I built the product hunt launch video gallery using Gatsby JS, Google Sheets, and Product Hunt API

by Ilias Haddad

In this tutorial, I'll be sharing how I built the Product Hunt launch video gallery which use Product Hunt API, store the data in Google Sheets and display it using Gatsby JS. You can check out the website from this link

What I'll be covering today:

  • Use Netlify functions to query data from Product Hunt API and store it in Google Sheets
  • Query data from Google Sheets and display it on Gatsby website
  • Download remote images to a local folder to utilize the power of Gatsby Images
  • Send GET requests using IFTTT to get fresh product launch video

Let's do it.

Use Netlify functions to query data from Product Hunt API and store it in Google Sheets

  • Install Gatsby CLI using Yarn
yarn global add gatsby-cli

or NPM

npm i gatsby-cli -g
  • Install Netlify Dev CLI to test Netlify functions locally using Yarn

yarn global add netlify-cli

or NPM

npm i netlify-cli -g
  • Create new gatsby website using CLI
gatsby new product-hunt-launch-video https://github.com/oddstronaut/gatsby-starter-tailwind
  • Run the Gatsby website
cd product-hunt-launch-video && gatsby develop
  • Create functions folder inside the root folder
cd src && mkdir functions
  • Create product-hunt.js file inside functions folder
touch product-hunt.js
  • In the root directory, install node-fetch to fetch data from GraphQl API, dotenv to load

env variables

yarn add node-fetch dotenv
  • We need to initialize a new netlify project
netlify init

and choose "No, I will connect this directory with GitHub first" and follow the instructions

mentioned the command line

  • Create netlify.toml file to config the netlfiy website
[build]command = "yarn run build"functions = "functions" # netlify dev uses this directory to scaffold apublish = "public"
  • Create env file to hold Product Hunt API Key
PH_ACCESS_TOKEN=

In order to get your Product Hunt API Key, you need to login with your Product Hunt account

and check this wesbite and create new application and when you create a new application.

you'll have a Developer Access Token which never expires and we'll use this token in env file

In the product-hunt.js, we'll create the function to consume Product Hunt API

require("dotenv").config()const fetch = require("node-fetch")exports.handler = function (event, context, callback) { const requestBody = { query: ` { posts(order:RANKING) { edges { node { name url topics { edges { node { name } } } votesCount media { videoUrl url } tagline createdAt } } } } `, };fetch("https://api.producthunt.com/v2/api/graphql", {method: "POST",headers: {authorization: `Bearer ${process.env.PH\_ACCESS\_TOKEN}`,"Content-type": "Application/JSON",},body: JSON.stringify(requestBody),}).then(res => res.json()).then(({ data }) => {callback(null, {statusCode: 200,body: JSON.stringify({message: "Success",data: data.posts.edges,}),})}).catch(err => console.log(err))}

You need to run this script and check http://localhost:8888/.netlify/functions/product-hunt

to send a GET request to this netlify function and then send a POST request to Product

Hunt GraphQl API

netlify dev
  • We need to filter the product that had a launch video
if (data) { const filterData = data.posts.edges.filter(el => { return el.node.media.map(el => el.videoUrl)[0] !== null }) callback(null, { statusCode: 200, body: JSON.stringify({ message: "Success", data: filterData, }), }) }

In this function, We checked if data is defined, filter posts data, map each product media array and return the videoUrl value, then we check if the first array item isn't null because the launch video is the first item in the media array

Now, our code will look like this

require("dotenv").config()const fetch = require("node-fetch")exports.handler = function (event, context, callback) { const date = new Date(event.queryStringParameters.date).toISOString(); const requestBody = { query: ` { posts(order:RANKING, postedBefore: ${date}) { edges { node { name url topics { edges { node { name } } } votesCount media { videoUrl url } tagline createdAt } } } } `, } fetch("https://api.producthunt.com/v2/api/graphql", { method: "POST", headers: { authorization: `Bearer ${process.env.PH_ACCESS_TOKEN}`, "Content-type": "Application/JSON", }, body: JSON.stringify(requestBody), }) .then(res => res.json()) .then(({ data }) => { if (data) { const filterData = data.posts.edges.filter(el => { return el.node.media.map(el => el.videoUrl)[0] !== null }) callback(null, { statusCode: 200, body: JSON.stringify({ message: "Success", data: filterData, }), }) } }) .catch(err => console.log(err))}

We're on the halfway to finish the netlify function

  • You need create new Google spreadsheets at Google Sheets
  • You need to get Google Sheets API credentials to be able to read data from sheets
  • Go to the Google APIs Console.
  • Create a new project.
  • Click Enable API. Search for and enable the Google Sheet API.
  • Create a new service account then, create a new API key and download the JSON file
  • Install Google Sheets Node JS SDK to add Product Hunt data to it
yarn add google-spreadsheet util && netlify dev
  • Access your Sheets using the Node JS SDK
const acessSepreadSheet = async () => { const doc = new GoogleSpreadsheet( "YOUR GOOGLE SHEET ID" ) await doc.useServiceAccountAuth({ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY, }) const info = await doc.loadInfo() // loads document properties and worksheets console.log(doc.title) }

This function will access the Google Sheet and return the sheet title

Now, we need this row to add new product data like mentioned the screenshots below.

Alt Text

We need to write a function to add a new row

const accessSpreadSheet = async ({ productName, topic, votesCount, videoUrl, featuredImage, url, created_at, description, }) => { const doc = new GoogleSpreadsheet( "YOUR SHEET ID" ) // use service account creds await doc.useServiceAccountAuth({ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY, }) await doc.loadInfo() // loads document properties and worksheets const sheet = doc.sheetsByIndex[0] // or use doc.sheetsById[id] const row = { productName, topic, votesCount, videoUrl, featuredImage, url, created_at, description, } await sheet.addRow(row) }

and the final code will look like this

require("dotenv").config()const fetch = require("node-fetch")const { GoogleSpreadsheet } = require("google-spreadsheet")exports.handler = function (event, context, callback) { const date = new Date(event.queryStringParameters.date).toISOString(); const accessSpreadSheet = async ({ productName, topic, votesCount, videoUrl, featuredImage, url, created_at, description, }) => { const doc = new GoogleSpreadsheet( "YOUR SHEET ID" ) // use service account creds await doc.useServiceAccountAuth({ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY, }) await doc.loadInfo() // loads document properties and worksheets const sheet = doc.sheetsByIndex[0] // or use doc.sheetsById[id] const row = { productName, topic, votesCount, videoUrl, featuredImage, url, created_at, description, } await sheet.addRow(row) } const requestBody = { query: ` { posts(order:RANKING, postedBefore: ${date}) { edges { node { name url topics { edges { node { name } } } votesCount media { videoUrl url } tagline createdAt } } } } `, } fetch("https://api.producthunt.com/v2/api/graphql", { method: "POST", headers: { authorization: `Bearer ${process.env.PH_ACCESS_TOKEN}`, "Content-type": "Application/JSON", }, body: JSON.stringify(requestBody), }) .then(res => res.json()) .then(async ({ data, status }) => { if (data) { const filterData = data.posts.edges.filter(el => { return el.node.media.map(el => el.videoUrl)[0] !== null }) callback(null, { statusCode: 200, body: JSON.stringify({ message: "Success", data: filterData.length, }), }) for (let index = 0; index < filterData.length; index++) { const product = filterData[index] await accessSpreadSheet({ productName: product.node.name, topic: product.node.topics.edges .map(({ node }) => node.name) .toString(), votesCount: product.node.votesCount, videoUrl: product.node.media[0].videoUrl, featuredImage: product.node.media[1].url, url: product.node.url, created_at: product.node.createdAt, description: product.node.tagline, }) } } }) .catch(err => console.log(err))}

And this the results

Alt Text

Query data from Google Sheets and display it in Gatsby website

  • Add gatsby plugin to query Google Sheets Data
yarn add gatsby-source-google-sheets
  • Add configuration to gatsby-config.js
{ resolve: "gatsby-source-google-sheets", options: { spreadsheetId: "YOUR SPREAD SHEET ID", worksheetTitle: "YOUR SHEET ID", credentials: { client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY, }, }, },

Don't forget to add this line of code to load env varibale in gatsby-config.js

require("dotenv").config()

Et Voila, we can query data from Google Sheets in our Gatsby website

Alt Text

  • We'll move the index.js page from the pages folder to a new folder called templates to add pagination
/* eslint-disable react/prop-types */import React from "react";import Layout from "../components/layout";import SEO from "../components/seo";import { graphql } from "gatsby";import { Link } from "gatsby";const IndexPage = ({ data, pageContext }) => { const { currentPage, numPages } = pageContext; const isFirst = currentPage === 1; const isLast = currentPage === numPages; const prevPage = currentPage - 1 === 1 ? "/page" : "/page/" + (currentPage - 1).toString(); const nextPage = "/page/" + (currentPage + 1).toString(); return ( <Layout> <SEO keywords={[ `producthunt`, `video`, `inspiration`, `product hunt launch video`, ]} title="Product Hunt Video Inspiration" /> <section className="container grid-cols-1 sm:grid-cols-2 md:grid-cols-3 mx-auto md:gap-y-24 gap-y-12 px-4 py-10 grid md:gap-10 "> {data.allGoogleSheetSheet1Row.edges .filter(({ node }) => node.localFeaturedImage !== null) .filter( (el, i, array) => array.findIndex( ({ node }, index) => node.productname !== index ) !== i ) .sort((a, b) => b.votescount - a.votescount) .map(({ node }) => ( <div className="md:flex flex-col" rel="noreferrer" data-videourl={node.videourl} key={node.id} > <div className="md:flex-shrink-0 overflow-hidden relative "> <div className="w-full h-full absolute opacity-0 hover:opacity-100 " style={{ zIndex: "99", position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", backgroundColor: "rgba(0, 0, 0, 0.45)", }} ></div> </div> <div className="mt-4 md:mt-3 "> <div className="uppercase tracking-wide text-sm text-indigo-600 font-bold"> {node.topic} </div> <a href={node.url} target="_blank" rel="noreferrer" className="inline-block mt-2 text-lg leading-tight font-semibold text-gray-900 hover:underline" > {node.productname} <span className="inline-block ml-4"></span> </a> <p className="mt-2 text-gray-600">{node.description}</p> </div> </div> ))} </section> <div className="bg-white px-4 py-3 flex items-center justify-center w-full border-t border-gray-200 sm:px-6"> <div className="flex-1 flex justify-between "> {!isFirst && ( <Link to={prevPage} rel="prev" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm leading-5 font-medium rounded-md text-gray-700 bg-white hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" > <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> </svg> Previous </Link> )} {!isLast && ( <Link to={nextPage} rel="next" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm leading-5 font-medium rounded-md text-gray-700 bg-white hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" > Next <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> </svg> </Link> )} </div> </div> </Layout> );};export default IndexPage;export const query = graphql` query ProductListQuery($skip: Int!, $limit: Int!) { allGoogleSheetSheet1Row( sort: { fields: votescount, order: DESC } limit: $limit skip: $skip ) { edges { node { featuredimage productname topic url votescount videourl id description } } } ProductSearch: allGoogleSheetSheet1Row( sort: { fields: votescount, order: DESC } ) { edges { node { featuredimage productname topic url votescount videourl id description } } } }`;
  • We need to create a new file (gatsby-node.js) in the root folder and add this code to it
exports.createPages = async ({ graphql, actions, reporter }) => { const { createPage } = actions; const result = await graphql( ` query MyQuery { allGoogleSheetSheet1Row(sort: { fields: votescount, order: DESC }) { edges { node { id } } } } ` ); if (result.errors) { reporter.panicOnBuild(`Error while running GraphQL query.`); return; } const posts = result.data.allGoogleSheetSheet1Row.edges; const postsPerPage = 50; const numPages = Math.ceil(posts.length / postsPerPage); console.log(numPages); Array.from({ length: numPages }).forEach((_, i) => { createPage({ path: i === 0 ? `/` : `/page/${i + 1}`, component: path.resolve("./src/templates/index.js"), context: { limit: postsPerPage, skip: i * postsPerPage, numPages, currentPage: i + 1, }, }); });};

In this code, we create the index page and divide the data with 50 products per page

Now, We have data in our Google SpreadSheet in Our Gatsby Website like in this screenshot

Alt Text

Download remote images to a local folder to utilize the power of Gatsby Images

Now, we have the data available but no images. we can use the URL of the image in the img tag. If we do this, we'll not utilize the image processing power of Gatsby's image and in order to utilize it, we need to download images from the URL and store locally.

  • First, install the gatsby-source-filesystem plugin
yarn add gatsby-source-filesystem gatsby-transformer-sharp gatsby-plugin-sharp
  • Add config of this plugin to gatsby-config.js to the first of plugins array
{ resolve: `gatsby-source-filesystem`, options: { name: `images`, path: path.join(__dirname, `src`, `images`), }, }, `gatsby-plugin-sharp`, `gatsby-transformer-sharp`,
  • Add this code to the gatsby-node.js file
const { createRemoteFileNode } = require("gatsby-source-filesystem");exports.onCreateNode = async ({ node, actions, store, cache, createNodeId,}) => { const { createNode } = actions; if (node.internal.type === "googleSheetSheet1Row") { try { const fileNode = await createRemoteFileNode({ url: node.featuredimage, store, cache, createNode, parentNodeId: node.id, createNodeId, }); if (fileNode) { node.localFeaturedImage___NODE = fileNode.id; } } catch (err) { node.localFeaturedImage = null; } }};

In this code, We listen to on creating node event and add like a proxy to download the remote image and we specify the field (node.featuredimage) .when it's downloaded we add a new node field called localFeaturedImage which will be available to query in GraphQl

  • Install Gatsby Image
yarn add gatsby-image

Now, the index.js file will look like this

/* eslint-disable react/prop-types */import React from "react";import Layout from "../components/layout";import SEO from "../components/seo";import { graphql } from "gatsby";import { Link } from "gatsby";import Img from "gatsby-image";const IndexPage = ({ data, pageContext }) => { const { currentPage, numPages } = pageContext; const isFirst = currentPage === 1; const isLast = currentPage === numPages; const prevPage = currentPage - 1 === 1 ? "/page" : "/page/" + (currentPage - 1).toString(); const nextPage = "/page/" + (currentPage + 1).toString(); return ( <Layout> <SEO keywords={[ `producthunt`, `video`, `inspiration`, `product hunt launch video`, ]} title="Product Hunt Video Inspiration" /> <section className="container grid-cols-1 sm:grid-cols-2 md:grid-cols-3 mx-auto md:gap-y-24 gap-y-12 px-4 py-10 grid md:gap-10 "> {data.allGoogleSheetSheet1Row.edges .filter(({ node }) => node.localFeaturedImage !== null) .filter( ({ node }) => node.localFeaturedImage.childImageSharp !== null ) .filter( (el, i, array) => array.findIndex( ({ node }, index) => node.productname !== index ) !== i ) .sort((a, b) => b.votescount - a.votescount) .map(({ node }) => ( <div className="md:flex flex-col" rel="noreferrer" data-videourl={node.videourl} key={node.id} > <div className="md:flex-shrink-0 overflow-hidden relative "> <div className="w-full h-full absolute opacity-0 hover:opacity-100 " style={{ zIndex: "99", position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", backgroundColor: "rgba(0, 0, 0, 0.45)", }} ></div> <Img fixed={node.localFeaturedImage.childImageSharp.fixed} objectFit="cover" objectPosition="50% 50%" className="cursor-pointer" imgStyle={{ display: "block", }} /> </div> <div className="mt-4 md:mt-3 "> <div className="uppercase tracking-wide text-sm text-indigo-600 font-bold"> {node.topic} </div> <a href={node.url} target="_blank" rel="noreferrer" className="inline-block mt-2 text-lg leading-tight font-semibold text-gray-900 hover:underline" > {node.productname} <span className="inline-block ml-4"></span> </a> <p className="mt-2 text-gray-600">{node.description}</p> </div> </div> ))} </section> <div className="bg-white px-4 py-3 flex items-center justify-center w-full border-t border-gray-200 sm:px-6"> <div className="flex-1 flex justify-between "> {!isFirst && ( <Link to={prevPage} rel="prev" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm leading-5 font-medium rounded-md text-gray-700 bg-white hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" > <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> </svg> Previous </Link> )} {!isLast && ( <Link to={nextPage} rel="next" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm leading-5 font-medium rounded-md text-gray-700 bg-white hover:text-gray-500 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" > Next <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> </svg> </Link> )} </div> </div> </Layout> );};export default IndexPage;export const query = graphql` query ProductListQuery($skip: Int!, $limit: Int!) { allGoogleSheetSheet1Row( sort: { fields: votescount, order: DESC } limit: $limit skip: $skip ) { edges { node { featuredimage productname topic url votescount videourl id description localFeaturedImage { childImageSharp { # Specify the image processing specifications right in the query. # Makes it trivial to update as your page's design changes. fixed(width: 400, height: 250) { ...GatsbyImageSharpFixed } } } } } } }`;

and this the results

Alt Text

Now, will add the modal

  • Create modal.js under components folder
import React, { useEffect } from "react";const Modal = ({ children, handleClose, show, closeHidden, video, videoTag, ...props}) => { useEffect(() => { document.addEventListener("keydown", keyPress); document.addEventListener("click", stopProgagation); return () => { document.removeEventListener("keydown", keyPress); document.removeEventListener("click", stopProgagation); }; }); useEffect(() => { handleBodyClass(); }, [props.show]); const handleBodyClass = () => { if (document.querySelectorAll(".modal.is-active").length) { document.body.classList.add("modal-is-active"); } else { document.body.classList.remove("modal-is-active"); } }; const keyPress = (e) => { e.keyCode === 27 && handleClose(e); }; const stopProgagation = (e) => { e.stopPropagation(); }; return ( <> {show && ( <div {...props} className="modal is-active modal-video" onClick={handleClose} > <div className="modal-inner " onClick={stopProgagation}> {video ? ( <div className="responsive-video"> {videoTag === "iframe" ? ( <iframe title="video" src={video} frameBorder="0" allowFullScreen ></iframe> ) : ( <video v-else controls src={video}></video> )} </div> ) : ( <> {!closeHidden && ( <button className="modal-close" aria-label="close" onClick={handleClose} ></button> )} <div className="modal-content">{children}</div> </> )} </div> </div> )} </> );};export default Modal;
  • Create new hero.js file under the components folder
import { Link } from "gatsby";import React from "react";export const Hero = () => { return ( <div className="relative bg-white overflow-hidden"> <div className="max-w-screen-xl mx-auto "> <div className="relative z-10 bg-white lg:w-full pb-8 text-center"> <div className="mt-10 mx-auto max-w-screen-xl px-4 sm:mt-12 sm:px-6 md:mt-16 lg:mt-20 lg:px-8 xl:mt-28 text-center"> <div className="sm:text-center lg:text-center"> <h2 className="text-4xl tracking-tight leading-10 font-extrabold text-gray-900 sm:text-5xl sm:leading-none md:text-6xl"> <span className="text-indigo-600">Discover </span> the best Product Hunt launch videos </h2> <p className="mt-3 text-center text-base text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:my-8 md:text-xl "> Curated product hunt launch videos to get inspiration for your next PH launch <br /> <span className="text-indigo-600 mt-2 block"> {" "} Note: click on the product image to watch the PH launch video </span> </p> <div className=" sm:flex sm:justify-center lg:justify-center"> <div className="rounded-md shadow"> <Link to="/search" className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:shadow-outline transition duration-150 ease-in-out md:py-4 md:text-lg md:px-10" > Search Videos </Link> </div> </div> </div> </div> </div> </div> </div> );};

Replace the CSS style to style.css file with this code to customize the video modal

/*! purgecss start ignore */@tailwind base;@tailwind components;/*! purgecss end ignore */@tailwind utilities;.modal.is-active { display: flex;}.modal { display: none; align-items: center; flex-direction: column; justify-content: center; overflow: hidden; position: fixed; z-index: 40;}.modal,.modal:before { bottom: 0; left: 0; right: 0; top: 0;}.modal.is-active .modal-inner { animation: slideUpInModal 0.15s ease-in-out both;}.modal.is-active .modal-inner,.modal.is-active:before { display: block;}.modal.modal-video .modal-inner { padding: 0; max-width: 1024px;}.modal .modal-inner,.modal:before { display: none;}@media (min-width: 641px) { .modal-inner { margin: 0 auto; max-height: calc(100vh - 96px); }}.modal-inner { max-height: calc(100vh - 32px); overflow: auto; position: relative; width: calc(100% - 32px); max-width: 520px; margin-left: 16px; margin-right: 16px; background: #25282c;}.responsive-video { position: relative; padding-bottom: 56.25%; height: 0;}.responsive-video iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%;}.modal.is-active:before { animation: slideUpInModalBg 0.15s ease-in-out both;}.modal.is-active .modal-inner,.modal.is-active:before { display: block;}.modal:before { content: ""; position: absolute; background-color: rgba(21, 23, 25, 0.88);}.modal .modal-inner,.modal:before { display: none;}.modal,.modal:before { bottom: 0; left: 0; right: 0; top: 0;}

Send GET requests using IFTTT to get fresh product launch video

We need to set up a cron job that will send a request data to the netlify function to get new product data and add it to Google Sheets. Then, run netlify build hook to rebuild the gatsby website

  • You need to create a new free account at IFTTT
  • Create a new applet and choose date and time. set the time to 00:00 PST which is the time when the new product at PH is launched
  • Choose webhook for then and set the GET request

The GET request URL will be your-netlify-wesbite/.netlify/functions/product-hunt?date= and check time as ingredient

After you need to create a new applet with the same services but in time will be 00:15 PST and POST request URL will be netlify build hook which you get it from netlify Build & Deploy settings

Source Code: https://github.com/IliasHad/product-hunt-launch-video-gallery-demo

  • JamStack
  • Product Hunt

Tell me about your project