First version of Dasher Supply astro site.
This commit is contained in:
commit
13947eb216
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
*.[jJ][pP][gG] filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.[jJ][pP][eE][gG] filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.[gG][iI][fF] filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.[pP][nN][gG] filter=lfs diff=lfs merge=lfs -text
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
54
README.md
Normal file
54
README.md
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# Astro Starter Kit: Basics
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template basics
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||||
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
│ └── favicon.svg
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── Card.astro
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
4
astro.config.mjs
Normal file
4
astro.config.mjs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({});
|
8446
package-lock.json
generated
Normal file
8446
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "dashersupply",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro check && astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.7.0",
|
||||||
|
"astro": "^4.11.0",
|
||||||
|
"bootstrap": "^5.3.3",
|
||||||
|
"cheerio": "*",
|
||||||
|
"crawlee": "^3.0.0",
|
||||||
|
"playwright": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@apify/tsconfig": "^0.1.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"tsx": "^4.4.0",
|
||||||
|
"typescript": "^5.5.2"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/assets/biking-gear.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/biking-gear.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/comfort-convenience.png
(Stored with Git LFS)
Normal file
BIN
public/assets/comfort-convenience.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/delivery-gear-3.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/delivery-gear-3.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/delivery-gear.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/delivery-gear.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/delivery-gear2.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/delivery-gear2.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/misc.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/misc.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/personal-items.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/personal-items.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/safety-equipment.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/safety-equipment.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/tech-gadgets.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/tech-gadgets.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/vehicle-essentials-2.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/vehicle-essentials-2.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/vehicle-essentials.png
(Stored with Git LFS)
Normal file
BIN
public/assets/vehicle-essentials.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/assets/wearable-tech.jpg
(Stored with Git LFS)
Normal file
BIN
public/assets/wearable-tech.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 749 B |
65
src/components/CategoryCard.astro
Normal file
65
src/components/CategoryCard.astro
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
import type { Category } from '../data/categories';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
category: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
const { category } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li class="link-card">
|
||||||
|
<a href={`/category/${category.seoLink}`}>
|
||||||
|
{category.imageUrl !== undefined && <img src={category.imageUrl} alt={category.category} />}
|
||||||
|
<h2>
|
||||||
|
{category.category}
|
||||||
|
<span>→</span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<style>
|
||||||
|
.link-card {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
padding: 1px;
|
||||||
|
background-color: #23262d;
|
||||||
|
background-image: none;
|
||||||
|
background-size: 400%;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-position: 100%;
|
||||||
|
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.link-card > a {
|
||||||
|
width: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: calc(1.5rem - 1px);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
background-color: #23262d;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.link-card img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) {
|
||||||
|
background-position: 0;
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) h2 {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
</style>
|
75
src/components/ProductCard.astro
Normal file
75
src/components/ProductCard.astro
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
import { type Product } from '../data/products';
|
||||||
|
import StarRating from './StarRating.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
product?: Product,
|
||||||
|
id?: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product, id } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card col-3">
|
||||||
|
<a href={`/${id}`}>
|
||||||
|
<div class="card-header">
|
||||||
|
{product?.name}
|
||||||
|
</div>
|
||||||
|
{product?.productDetails?.imageUrls !== undefined && <img src={product!.productDetails?.imageUrls[0]} alt={product?.productDetails?.title} />}
|
||||||
|
<div class="card-body">
|
||||||
|
<StarRating value={product?.productDetails?.reviewRating} max={5} /> {product?.productDetails?.reviewCount} Reviews
|
||||||
|
<h5 class="card-title">
|
||||||
|
{product?.productDetails?.title}
|
||||||
|
</h5>
|
||||||
|
<!-- <a href={product?.amazonLink} class="btn btn-primary">${product?.productDetails?.price} On Amazon <span>→</span></a> -->
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.link-card {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
padding: 1px;
|
||||||
|
background-color: #23262d;
|
||||||
|
background-image: none;
|
||||||
|
background-size: 400%;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-position: 100%;
|
||||||
|
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.link-card > a {
|
||||||
|
width: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: calc(1.5rem - 1px);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
background-color: #23262d;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) {
|
||||||
|
background-position: 0;
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
.link-card:is(:hover, :focus-within) h2 {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card a {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.card a img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
45
src/components/StarRating.astro
Normal file
45
src/components/StarRating.astro
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value = 0, max = 5, overlayColor = '#fff' } = Astro.props;
|
||||||
|
const percentage = Math.round((value / max) * 100);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="star-container">
|
||||||
|
{Array.from(Array(max).keys()).map((_, i) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="star" width="16" height="16" fill="currentColor" class="bi bi-star-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
<div class="star-overlay" style={{ width: `${100 - percentage}%`, 'background-color': `${overlayColor}` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.star-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.star {
|
||||||
|
width: 18px;
|
||||||
|
margin-right: 2px;
|
||||||
|
display: flex;
|
||||||
|
color: #f8d448;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.star-overlay {
|
||||||
|
position: absolute;
|
||||||
|
background-color: '#fff';
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
122
src/data/categories.ts
Normal file
122
src/data/categories.ts
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Category of Products.
|
||||||
|
*/
|
||||||
|
export interface Category {
|
||||||
|
/**
|
||||||
|
* Numeric ID for Product Category.
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Name for Product Category.
|
||||||
|
*/
|
||||||
|
category: string;
|
||||||
|
/**
|
||||||
|
* SEO-optimized link for Product Category.
|
||||||
|
*/
|
||||||
|
seoLink: string;
|
||||||
|
/**
|
||||||
|
* Image for the category.
|
||||||
|
*/
|
||||||
|
imageUrl?: string;
|
||||||
|
/**
|
||||||
|
* Description of Product Category.
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class StaticCategory {
|
||||||
|
/**
|
||||||
|
* Last ID assigned to a category.
|
||||||
|
*/
|
||||||
|
static lastId: number = 0;
|
||||||
|
/**
|
||||||
|
* Gets the next ID for a category.
|
||||||
|
*
|
||||||
|
* @returns A new value for a category.
|
||||||
|
*/
|
||||||
|
static nextId(): number {
|
||||||
|
return ++this.lastId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all the categories.
|
||||||
|
*/
|
||||||
|
export const categories: Category[] = [
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Vehicle Essentials",
|
||||||
|
seoLink: "vehicle-essentials",
|
||||||
|
imageUrl: "/assets/vehicle-essentials.png",
|
||||||
|
description: "Essential items for your vehicle to ensure smooth deliveries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Delivery Gear",
|
||||||
|
seoLink: "delivery-gear",
|
||||||
|
imageUrl: "/assets/delivery-gear-3.jpg",
|
||||||
|
description: "Gear to help you deliver food efficiently and keep it in top condition.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Personal Items",
|
||||||
|
seoLink: "personal-items",
|
||||||
|
imageUrl: "/assets/personal-items.jpg",
|
||||||
|
description: "Personal essentials to keep you comfortable and prepared on the go.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Safety Equipment",
|
||||||
|
seoLink: "safety-equipment",
|
||||||
|
imageUrl: "/assets/safety-equipment.jpg",
|
||||||
|
description: "Safety gear to protect you during deliveries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Tech Gadgets",
|
||||||
|
seoLink: "tech-gadgets",
|
||||||
|
imageUrl: "/assets/tech-gadgets.jpg",
|
||||||
|
description: "Technology tools to enhance your efficiency and connectivity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Biking Gear",
|
||||||
|
seoLink: "biking-gear",
|
||||||
|
imageUrl: "/assets/biking-gear.jpg",
|
||||||
|
description: "Equipment for Dashers who deliver by bike.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Comfort and Convenience",
|
||||||
|
seoLink: "comfort-convenience",
|
||||||
|
imageUrl: "/assets/comfort-convenience.png",
|
||||||
|
description: "Items to increase comfort and convenience during deliveries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Health and Wellness",
|
||||||
|
seoLink: "health-wellness",
|
||||||
|
imageUrl: "/assets/wearable-tech.jpg",
|
||||||
|
description: "Products to help you stay healthy and well during your shifts.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: StaticCategory.nextId(),
|
||||||
|
category: "Miscellaneous",
|
||||||
|
seoLink: "misc",
|
||||||
|
imageUrl: "/assets/misc.jpg",
|
||||||
|
description: "Various other items that can be useful for Dashers.",
|
||||||
|
}
|
||||||
|
].sort((a, b) => a.seoLink.localeCompare(b.seoLink));
|
||||||
|
|
||||||
|
export function getCategoryIdForSeoLink(seoLink: string): number|null {
|
||||||
|
console.log('getCategoryIdForSeoLink looking for ', seoLink, 'in', categories);
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.seoLink == seoLink) {
|
||||||
|
return category.id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return null;
|
||||||
|
}
|
4
src/data/product-attribute.ts
Normal file
4
src/data/product-attribute.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export type ProductAttribute = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
13
src/data/product-details.ts
Normal file
13
src/data/product-details.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import type { ProductAttribute } from "./product-attribute";
|
||||||
|
|
||||||
|
export type ProductDetails = {
|
||||||
|
title?: string;
|
||||||
|
price?: number;
|
||||||
|
// listPrice?: number;
|
||||||
|
description?: string;
|
||||||
|
featureBullets?: string[];
|
||||||
|
reviewRating?: number;
|
||||||
|
reviewCount?: number;
|
||||||
|
imageUrls?: string[];
|
||||||
|
attributes?: ProductAttribute[];
|
||||||
|
};
|
278
src/data/products.ts
Normal file
278
src/data/products.ts
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
import { getCategoryIdForSeoLink } from './categories';
|
||||||
|
import type { ProductDetails } from './product-details';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product details.
|
||||||
|
*/
|
||||||
|
export interface Product {
|
||||||
|
/**
|
||||||
|
* Name of the product.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
// /**
|
||||||
|
// * Description of the product.
|
||||||
|
// */
|
||||||
|
// description: string;
|
||||||
|
// /**
|
||||||
|
// * Amazon link for the product.
|
||||||
|
// */
|
||||||
|
amazonLink: string;
|
||||||
|
/**
|
||||||
|
* Tags associated with the product.
|
||||||
|
*/
|
||||||
|
tags: string[];
|
||||||
|
/**
|
||||||
|
* ID of the category this product belongs to.
|
||||||
|
*/
|
||||||
|
categoryId: number;
|
||||||
|
/**
|
||||||
|
* Product Details.
|
||||||
|
*/
|
||||||
|
productDetails?: ProductDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all the products.
|
||||||
|
*/
|
||||||
|
export const products: Product[] = [
|
||||||
|
{
|
||||||
|
amazonLink: 'https://amzn.to/3W4H5rd',
|
||||||
|
// amazonLink: 'https://www.amazon.com/Rubbermaid-Commercial-Deluxe-Cleaning-FG315488BLA/dp/B00006ICOT?crid=23IAS1CUMM6QG&dib=eyJ2IjoiMSJ9.WRH21whjlnubmVRL4HRNIccU9p3CC9B9pvd9LCCkzqxXQggwnV0UNwmgHs868sL9Jr_1cfUHxsHCU7sTT28EMZOCdxoGo-ylie7hWbrQ75ab9SFUJMawaE14LhyNFAQ69j45EtR9kd0njMvXY9WDrBWj61TMpe6K1vl0BC-kWFz8iQqZgrRsgLNN5jbuF83nWOddYMTMZFxQXuvyPUG13LwYmOe17iPUBa03FNecKl0.-fxaqjBgRSTfoIeqegQhb9rz9lE9LJTt475JTTi0J3A&dib_tag=se&keywords=drink+carrier&qid=1719716583&sprefix=drink+carrier%2Caps%2C162&sr=8-3&linkCode=ll1&tag=radspazzyspaz-20&linkId=50dbc148d6ed4c95a175ce34d86775f8&language=en_US&ref_=as_li_ss_tl',
|
||||||
|
tags: ['drink carrier'],
|
||||||
|
categoryId: getCategoryIdForSeoLink('delivery-gear')!,
|
||||||
|
name: 'Industrial Drink Carrier',
|
||||||
|
productDetails: {
|
||||||
|
"title": "Rubbermaid Commercial Products Deluxe Carry Caddy for Take-Out Coffee/Soft Drinks, Postmates/Uber Eats/Food Delivery, Cleaning Products, Sports/Water Bottles, Black",
|
||||||
|
"description": "The Rubbermaid Commercial Deluxe Carry Cleaning Caddy is an all-purpose cleaning supply caddy with 8 rounded sections ideal for carrying and storing things such as tools, cleaning supplies, spray bottles, sports drink bottles, and other drinks. This products is ideal for those looking for a tool to transport their frequently used cleaning tools throughout their household or from job to job. This caddy is also ideal for Post mates or Uber Eats drivers who frequently need to carry multiple drinks such as coffee, smoothies, or large sodas. This caddy also works well as a holder for sports drinks and bottles and is therefore ideal for gyms, sports facilities, coaches, and others needing easy access and mobility to their drink bottles.",
|
||||||
|
"featureBullets": [
|
||||||
|
"ALL-PURPOSE: Heavy-duty caddy conveniently fits on cleaning and housekeeping carts",
|
||||||
|
"DRINK TRANSPORTATION: Ideal for carrying and transporting multiple drink cups, coffee cups, water bottles, smoothies, etc",
|
||||||
|
"CAR WASH KIT: Holds and stores car cleaning sprays, soaps, sponges, rags, gloves and detail tools",
|
||||||
|
"CAPACITY: Securely holds up to (8) 32-ounce bottles and other cleaning tools",
|
||||||
|
"DURABLE CONSTRUCTION: Deluxe Carry Caddy is designed with durability in mind for any professionals to use",
|
||||||
|
"COMPACT DESIGN: Compact design for easy storage",
|
||||||
|
"DIMENSIONS: 15\" x 10-9/10\" x 7-2/5\"",
|
||||||
|
"MADE IN THE USA: Made in the USA with global components"
|
||||||
|
],
|
||||||
|
"price": 16.97,
|
||||||
|
"reviewCount": 16374,
|
||||||
|
"reviewRating": 4.6,
|
||||||
|
"imageUrls": [
|
||||||
|
"https://m.media-amazon.com/images/I/51+PMTqd+TL._SX522_.jpg",
|
||||||
|
"https://m.media-amazon.com/images/I/61lZcvsWF3L._SX522_.jpg",
|
||||||
|
"https://m.media-amazon.com/images/I/61+1q9iEy0L._SX522_.jpg",
|
||||||
|
"https://m.media-amazon.com/images/I/61QgoYWq-9L._SX522_.jpg",
|
||||||
|
"https://m.media-amazon.com/images/I/61cnlUoV8RL._SX522_.jpg",
|
||||||
|
"https://m.media-amazon.com/images/I/715sdpAYsfL._SX522_.jpg"
|
||||||
|
],
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"label": "Specific Uses For Product",
|
||||||
|
"value": "Beverages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Material",
|
||||||
|
"value": "Polyethylene (PE)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Special Feature",
|
||||||
|
"value": "Durable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Color",
|
||||||
|
"value": "Black"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Brand",
|
||||||
|
"value": "Rubbermaid Commercial Products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Finish Type",
|
||||||
|
"value": "Varnished"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Product Dimensions",
|
||||||
|
"value": "16\"D x 11\"W x 8\"H"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Capacity",
|
||||||
|
"value": "2 Pounds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Unit Count",
|
||||||
|
"value": "1.0 Count"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Package Type",
|
||||||
|
"value": "Standard Packaging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Item Weight408 Grams",
|
||||||
|
"value": "Item Weight408 GramsNumber of Compartments2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: 'Car Phone Mount',
|
||||||
|
// description: "Essential for navigation.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ1234',
|
||||||
|
// tags: ['phone mount', 'navigation', 'car'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('vehicle-essentials')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Car Charger',
|
||||||
|
// description: "Keep your phone charged during long shifts.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ5678',
|
||||||
|
// tags: ['charger', 'car', 'accessory'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('vehicle-essentials')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Insulated Delivery Bag',
|
||||||
|
// description: "Keep food at the right temperature.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ9101',
|
||||||
|
// tags: ['delivery bag', 'insulated', 'food'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('delivery-gear')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Drink Carrier',
|
||||||
|
// description: "Securely transport multiple beverages.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ1213',
|
||||||
|
// tags: ['drink carrier', 'beverages', 'delivery'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('delivery-gear')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Portable Phone Charger',
|
||||||
|
// description: "Backup power for your phone.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ1415',
|
||||||
|
// tags: ['phone charger', 'portable', 'backup'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('personal-items')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Comfortable Clothing',
|
||||||
|
// description: "Weather-appropriate attire.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ1617',
|
||||||
|
// tags: ['clothing', 'comfortable', 'weather'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('personal-items')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Reflective Vest',
|
||||||
|
// description: "Increase visibility, especially for night deliveries.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ1819',
|
||||||
|
// tags: ['reflective vest', 'safety', 'visibility'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('safety-equipment')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'First Aid Kit',
|
||||||
|
// description: "Be prepared for minor injuries.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ2021',
|
||||||
|
// tags: ['first aid', 'safety', 'kit'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('safety-equipment')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Smartphone',
|
||||||
|
// description: "Reliable phones with good battery life and performance.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ2223',
|
||||||
|
// tags: ['smartphone', 'tech', 'battery life'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('tech-gadgets')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Bluetooth Headset',
|
||||||
|
// description: "For hands-free communication.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ2425',
|
||||||
|
// tags: ['bluetooth headset', 'communication', 'hands-free'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('tech-gadgets')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Bike Lock',
|
||||||
|
// description: "Secure your bike while making deliveries.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ2627',
|
||||||
|
// tags: ['bike lock', 'security', 'biking'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('biking-gear')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Bike Lights',
|
||||||
|
// description: "For visibility and safety during night rides.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ2829',
|
||||||
|
// tags: ['bike lights', 'visibility', 'night'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('biking-gear')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Portable Cooler',
|
||||||
|
// description: "Keep drinks and snacks cold during shifts.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ3031',
|
||||||
|
// tags: ['cooler', 'portable', 'convenience'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('comfort-convenience')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Sunshade',
|
||||||
|
// description: "Protect your car's interior and keep it cool.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ3233',
|
||||||
|
// tags: ['sunshade', 'car', 'protection'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('comfort-convenience')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Fitness Tracker',
|
||||||
|
// description: "Monitor your activity and health.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ3435',
|
||||||
|
// tags: ['fitness tracker', 'health', 'activity'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('health-wellness')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Healthy Snacks',
|
||||||
|
// description: "Convenient, healthy snacks for long shifts.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ3637',
|
||||||
|
// tags: ['healthy snacks', 'convenience', 'health'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('health-wellness')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Flashlight',
|
||||||
|
// description: "Handy for night deliveries.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ3839',
|
||||||
|
// tags: ['flashlight', 'night', 'deliveries'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('misc')!,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Multi-tool',
|
||||||
|
// description: "Useful for various minor fixes and adjustments.",
|
||||||
|
// amazonLink: 'https://www.amazon.com/dp/B08XYZ4041',
|
||||||
|
// tags: ['multi-tool', 'utility', 'fixes'],
|
||||||
|
// categoryId: getCategoryIdForSeoLink('misc')!
|
||||||
|
// }
|
||||||
|
];
|
||||||
|
|
||||||
|
// import { CheerioCrawler, type CheerioCrawlingContext, log } from 'crawlee';
|
||||||
|
// import { extractProductDetails } from '../scraper/amazon';
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Performs the logic of the crawler. It is called for each URL to crawl.
|
||||||
|
// * - Passed to the crawler using the `requestHandler` option.
|
||||||
|
// */
|
||||||
|
// const requestHandler = async (context: CheerioCrawlingContext) => {
|
||||||
|
// const { $, request } = context;
|
||||||
|
// const { url } = request;
|
||||||
|
|
||||||
|
// log.info(`Scraping product page`, { url });
|
||||||
|
// const extractedProduct = extractProductDetails($);
|
||||||
|
|
||||||
|
// log.info(`Scraped product details for "${extractedProduct.title}", saving...`, { url });
|
||||||
|
// crawler.pushData(extractedProduct);
|
||||||
|
|
||||||
|
// for (let p = 0; p < products.length; p++) {
|
||||||
|
// if (products[p].amazonLink == url) {
|
||||||
|
// products[p].productDetails = extractedProduct;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * The crawler instance. Crawlee provides a few different crawlers, but we'll use CheerioCrawler, as it's very fast and simple to use.
|
||||||
|
// * - Alternatively, we could use a full browser crawler like `PlaywrightCrawler` to imitate a real browser.
|
||||||
|
// */
|
||||||
|
// const crawler = new CheerioCrawler({ requestHandler });
|
||||||
|
|
||||||
|
// await crawler.run(products.map((p) => p.amazonLink));
|
||||||
|
|
||||||
|
// // await crawler.run([
|
||||||
|
// // 'https://www.amazon.com/Rubbermaid-Commercial-Deluxe-Cleaning-FG315488BLA/dp/B00006ICOT?crid=23IAS1CUMM6QG&dib=eyJ2IjoiMSJ9.WRH21whjlnubmVRL4HRNIccU9p3CC9B9pvd9LCCkzqxXQggwnV0UNwmgHs868sL9Jr_1cfUHxsHCU7sTT28EMZOCdxoGo-ylie7hWbrQ75ab9SFUJMawaE14LhyNFAQ69j45EtR9kd0njMvXY9WDrBWj61TMpe6K1vl0BC-kWFz8iQqZgrRsgLNN5jbuF83nWOddYMTMZFxQXuvyPUG13LwYmOe17iPUBa03FNecKl0.-fxaqjBgRSTfoIeqegQhb9rz9lE9LJTt475JTTi0J3A&dib_tag=se&keywords=drink+carrier&qid=1719716583&sprefix=drink+carrier,aps,162&sr=8-3&linkCode=sl1&tag=radspazzyspaz-20&linkId=4b1f972cd47168ab215cd7c8fecbefa8&language=en_US&ref_=as_li_ss_tl'
|
||||||
|
// // ]);
|
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="astro/client" />
|
61
src/layouts/Layout.astro
Normal file
61
src/layouts/Layout.astro
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import bootstrap from "bootstrap/dist/js/bootstrap.min.js?url";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Astro description" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<meta name="description" content="Your one-stop shop for all your after-market Dasher supplies." />
|
||||||
|
<title>{title}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.9" integrity="sha384-QFjmbokDn2DjBjq+fM+8LUIVrAgqcNW2s0PjAxHETgRn9l4fvX31ZxDxvwQnyMOX" crossorigin="anonymous"></script>
|
||||||
|
<script src={bootstrap}></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Holtwood+One+SC&family=Protest+Strike&family=Charm:wght@400;700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Urbanist:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<style is:global>
|
||||||
|
a{color: rgb(210, 20, 4))}
|
||||||
|
:root {
|
||||||
|
--accent: 210, 20, 4;
|
||||||
|
--accent-light: 255, 48, 8;
|
||||||
|
--accent-dark: 185, 14, 10;
|
||||||
|
--accent-gradient: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
rgb(var(--accent)),
|
||||||
|
rgb(var(--accent-light)) 10%,
|
||||||
|
white 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
font-family: "Noto Sans", system-ui, sans-serif;
|
||||||
|
background-color: #13151a !important;
|
||||||
|
/* background-size: 224px; */
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family:
|
||||||
|
Menlo,
|
||||||
|
Monaco,
|
||||||
|
Lucida Console,
|
||||||
|
Liberation Mono,
|
||||||
|
DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono,
|
||||||
|
Courier New,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
</style>
|
132
src/pages/[productLookup].astro
Normal file
132
src/pages/[productLookup].astro
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { categories } from '../data/categories';
|
||||||
|
import { products } from '../data/products';
|
||||||
|
import StarRating from '../components/StarRating.astro';
|
||||||
|
|
||||||
|
type ProductStaticPath = { params: { productLookup: string }};
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return products.map<ProductStaticPath>((_, index) => { return {
|
||||||
|
params: {
|
||||||
|
productLookup: index
|
||||||
|
}
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
console.log(getStaticPaths());
|
||||||
|
|
||||||
|
const { productLookup } = Astro.params;
|
||||||
|
const product = products[productLookup];
|
||||||
|
const category = categories[product.categoryId];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title=`${product?.name} - Dasher Supply`>
|
||||||
|
<main>
|
||||||
|
<h1 class="center"><a href="/"><span class="text-gradient">Dasher Supply</span></a> > <a href={`/category/${category.seoLink}`}>{category.category}</a> > {product.name}</h1>
|
||||||
|
<p class="instructions">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
<h2>
|
||||||
|
{product?.name}
|
||||||
|
</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
{product?.productDetails?.imageUrls !== undefined && <img src={product!.productDetails?.imageUrls[0]} alt={product?.productDetails?.title} />}
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<h5 class="card-title">
|
||||||
|
{product?.productDetails?.title}
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
<StarRating value={product?.productDetails?.reviewRating} max={5} overlayColor="#13151a" /> {product?.productDetails?.reviewCount} Reviews
|
||||||
|
</p>
|
||||||
|
<ul role="list">
|
||||||
|
{product?.productDetails?.featureBullets?.map(featureBullet => (
|
||||||
|
<li>{featureBullet}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
{product?.productDetails?.description && product?.productDetails?.description}
|
||||||
|
</p>
|
||||||
|
<a href={product?.amazonLink} class="btn btn-primary">${product?.productDetails?.price} On Amazon <span>→</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="disclaimers">
|
||||||
|
<p>Dasher Supply is not endorsed by or affiliated with DoorDash. As an Amazon Associate I earn from qualifying purchases.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 800px;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-family: "Holtwood One SC", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: bold;
|
||||||
|
/* text-shadow: -5px -5px 0 rgba(var(--accent-dark), 17%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%); */
|
||||||
|
}
|
||||||
|
.text-gradient {
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-size: 400%;
|
||||||
|
background-position: 0%;
|
||||||
|
}
|
||||||
|
.disclaimers {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Urbanist", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Charm", cursive;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.instructions code {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba(var(--accent-light), 12%);
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3em 0.4em;
|
||||||
|
}
|
||||||
|
.instructions strong {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
.link-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a, a:link, a:visited { text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; color: #fff }
|
||||||
|
</style>
|
116
src/pages/category/[categoryLookup].astro
Normal file
116
src/pages/category/[categoryLookup].astro
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
---
|
||||||
|
import Layout from '../../layouts/Layout.astro';
|
||||||
|
// import { useState, useEffect } from 'react';
|
||||||
|
import { categories } from '../../data/categories';
|
||||||
|
import { products } from '../../data/products';
|
||||||
|
import ProductCard from '../../components/ProductCard.astro';
|
||||||
|
|
||||||
|
type CategoryStaticPath = { params: { categoryLookup: string }};
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return categories.map<CategoryStaticPath>((category) => { return {
|
||||||
|
params: {
|
||||||
|
categoryLookup: category.seoLink
|
||||||
|
}
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
console.log(getStaticPaths());
|
||||||
|
|
||||||
|
const { categoryLookup } = Astro.params;
|
||||||
|
const category = categories.find(c => c.seoLink === categoryLookup)!;
|
||||||
|
const categoryProducts = products.filter(p => p.categoryId === category.id)!;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${category.category} - Dasher Supply`}>
|
||||||
|
<main>
|
||||||
|
<h1 class="center"><a href="/"><span class="text-gradient">Dasher Supply</span></a> > {category.category}</h1>
|
||||||
|
<p class="instructions">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
<div role="row">
|
||||||
|
{categoryProducts.map((product, id) => (
|
||||||
|
<ProductCard
|
||||||
|
product={product}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="disclaimers">
|
||||||
|
<p>Dasher Supply is not endorsed by or affiliated with DoorDash. As an Amazon Associate I earn from qualifying purchases.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 800px;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-family: "Holtwood One SC", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: bold;
|
||||||
|
/* text-shadow: -5px -5px 0 rgba(var(--accent-dark), 17%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%); */
|
||||||
|
}
|
||||||
|
.text-gradient {
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-size: 400%;
|
||||||
|
background-position: 0%;
|
||||||
|
}
|
||||||
|
.disclaimers {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Urbanist", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Charm", cursive;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.instructions code {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba(var(--accent-light), 12%);
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3em 0.4em;
|
||||||
|
}
|
||||||
|
.instructions strong {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
.link-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a, a:link, a:visited { text-decoration: none; }
|
||||||
|
a:hover, a:active { text-decoration: underline; color: #fff }
|
||||||
|
</style>
|
95
src/pages/index.astro
Normal file
95
src/pages/index.astro
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import CategoryCard from '../components/CategoryCard.astro';
|
||||||
|
import { categories } from '../data/categories';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Dasher Supply">
|
||||||
|
<main>
|
||||||
|
<h1 class="center"><span class="text-gradient">Dasher Supply</span></h1>
|
||||||
|
<p class="instructions">
|
||||||
|
Your one-stop shop for all your after-market Dasher supplies.
|
||||||
|
</p>
|
||||||
|
<ul role="list" class="link-card-grid">
|
||||||
|
{categories.map(category => (
|
||||||
|
<CategoryCard
|
||||||
|
category={category}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div class="disclaimers">
|
||||||
|
<p>Dasher Supply is not endorsed by or affiliated with DoorDash. As an Amazon Associate I earn from qualifying purchases.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 800px;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-family: "Holtwood One SC", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: bold;
|
||||||
|
/* text-shadow: -5px -5px 0 rgba(var(--accent-dark), 17%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%); */
|
||||||
|
}
|
||||||
|
.text-gradient {
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-size: 400%;
|
||||||
|
background-position: 0%;
|
||||||
|
}
|
||||||
|
.disclaimers {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Urbanist", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(var(--accent-light), 25%);
|
||||||
|
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Charm", cursive;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.instructions code {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba(var(--accent-light), 12%);
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3em 0.4em;
|
||||||
|
}
|
||||||
|
.instructions strong {
|
||||||
|
color: rgb(var(--accent-light));
|
||||||
|
}
|
||||||
|
.link-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
84
src/scraper/amazon.ts
Normal file
84
src/scraper/amazon.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Used temporarily until I get access to Amazon Product API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import cheerio, { type CheerioAPI } from 'cheerio';
|
||||||
|
import type { ProductDetails } from '../data/product-details';
|
||||||
|
import type { ProductAttribute } from '../data/product-attribute';
|
||||||
|
import { parseNumberFromSelector } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS selectors for the product details. Feel free to figure out different variations of these selectors.
|
||||||
|
*/
|
||||||
|
const SELECTORS = {
|
||||||
|
TITLE: 'span#productTitle',
|
||||||
|
PRICE: 'span.priceToPay',
|
||||||
|
// LIST_PRICE: 'span.basisPrice .a-offscreen',
|
||||||
|
FEATURE_BULLETS: '#feature-bullets li span.a-list-item',
|
||||||
|
DESCRIPTION: '#productDescription',
|
||||||
|
REVIEW_RATING: '#acrPopover a > span',
|
||||||
|
REVIEW_COUNT: '#acrCustomerReviewText',
|
||||||
|
IMAGES: '#altImages .item img',
|
||||||
|
PRODUCT_ATTRIBUTE_ROWS: '#productOverview_feature_div tr',
|
||||||
|
ATTRIBUTES_LABEL: 'td:nth-of-type(1) span',
|
||||||
|
ATTRIBUTES_VALUE: 'td:nth-of-type(2) span',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the product image URLs from the given Cheerio object.
|
||||||
|
* - We have to iterate over the image elements and extract the `src` attribute.
|
||||||
|
*/
|
||||||
|
const extractImageUrls = ($: CheerioAPI): string[] => {
|
||||||
|
const imageUrls = $(SELECTORS.IMAGES)
|
||||||
|
.map((_, imageEl) => $(imageEl).attr('src'))
|
||||||
|
.get(); // `get()` - Retrieve all elements matched by the Cheerio object, as an array. Removes `undefined` values.
|
||||||
|
|
||||||
|
return imageUrls;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the product attributes from the given Cheerio object.
|
||||||
|
* - We have to iterate over the attribute rows and extract both label and value for each row.
|
||||||
|
*/
|
||||||
|
const extractProductAttributes = ($: CheerioAPI): ProductAttribute[] => {
|
||||||
|
const attributeRowEls = $(SELECTORS.PRODUCT_ATTRIBUTE_ROWS).get();
|
||||||
|
|
||||||
|
const attributeRows = attributeRowEls.map((rowEl) => {
|
||||||
|
const label = $(rowEl).find(SELECTORS.ATTRIBUTES_LABEL).text();
|
||||||
|
const value = $(rowEl).find(SELECTORS.ATTRIBUTES_VALUE).text();
|
||||||
|
|
||||||
|
return { label, value };
|
||||||
|
});
|
||||||
|
|
||||||
|
return attributeRows;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the feature bullets from the given Cheerio object.
|
||||||
|
* - We have to iterate over the li elements and extract the text.
|
||||||
|
*/
|
||||||
|
const extractFeatureBullets = ($: CheerioAPI): string[] => {
|
||||||
|
const featureBullets = $(SELECTORS.FEATURE_BULLETS)
|
||||||
|
.map((_, featureBulletEl) => $(featureBulletEl).text().trim())
|
||||||
|
.get(); // `get()` - Retrieve all elements matched by the Cheerio object, as an array. Removes `undefined` values.
|
||||||
|
return featureBullets;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrapes the product details from the given Cheerio object.
|
||||||
|
*/
|
||||||
|
export const extractProductDetails = ($: CheerioAPI): ProductDetails => {
|
||||||
|
const title = $(SELECTORS.TITLE).text().trim();
|
||||||
|
const description = $(SELECTORS.DESCRIPTION).text()!.trim();
|
||||||
|
|
||||||
|
const price = parseNumberFromSelector($, SELECTORS.PRICE);
|
||||||
|
// const listPrice = parseNumberFromSelector($, SELECTORS.LIST_PRICE);
|
||||||
|
const reviewRating = parseNumberFromSelector($, SELECTORS.REVIEW_RATING);
|
||||||
|
const reviewCount = parseNumberFromSelector($, SELECTORS.REVIEW_COUNT);
|
||||||
|
|
||||||
|
const imageUrls = extractImageUrls($);
|
||||||
|
const attributes = extractProductAttributes($);
|
||||||
|
const featureBullets = extractFeatureBullets($);
|
||||||
|
|
||||||
|
return { title, description, featureBullets, price, /*listPrice,*/ reviewCount, reviewRating, imageUrls, attributes };
|
||||||
|
};
|
15
src/scraper/utils.ts
Normal file
15
src/scraper/utils.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Parses a number from a string by removing all non-numeric characters.
|
||||||
|
* - Keeps the decimal point.
|
||||||
|
*/
|
||||||
|
const parseNumberValue = (rawString: string): number => {
|
||||||
|
return Number(rawString.replace(/[^\d.]+/g, ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a number value from the first element matching the given selector.
|
||||||
|
*/
|
||||||
|
export const parseNumberFromSelector = ($: CheerioAPI, selector: string): number => {
|
||||||
|
const rawValue = $(selector).first().text();
|
||||||
|
return parseNumberValue(rawValue);
|
||||||
|
};
|
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user