Updated site to use Squidex instead of Strapi and instead of hardcoded data.

This commit is contained in:
David Ball 2024-08-21 08:55:19 -04:00
parent 98305aad31
commit 7354d923d0
122 changed files with 16513 additions and 2394 deletions

33
.dockerignore Normal file
View File

@ -0,0 +1,33 @@
# 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/
# VS Code setting folder
.vscode/
# git repository isn't needed inside the volume
.git/
# workaround: https://github.com/npm/cli/issues/4828
#package-lock.json

View File

@ -2,8 +2,17 @@ AMAZON_PA_ACCESS_KEY=AAAABBBBCCCCDDDDEEEE
AMAZON_PA_SECRET_KEY=ABCDABCDABCDABCDABCDABCDABCDABCDABCDAB AMAZON_PA_SECRET_KEY=ABCDABCDABCDABCDABCDABCDABCDABCDABCDAB
AMAZON_PA_HOST=webservices.amazon.com AMAZON_PA_HOST=webservices.amazon.com
AMAZON_PA_REGION=us-east-1 AMAZON_PA_REGION=us-east-1
AMAZON_PA_PARTNER_TYPE=Associate AMAZON_PA_PARTNER_TYPE=Associates
AMAZON_PA_PARTNER_TAG=yourpartnertag-20 AMAZON_PA_PARTNER_TAG=yourpartnertag-20
GOOGLE_ADSENSE_ADS_TXT="google.com, pub-1234567890abcdef, DIRECT, fedcba9876543210" GOOGLE_ADSENSE_ADS_TXT="google.com, pub-1234567890abcdef, DIRECT, fedcba9876543210"
GOOGLE_ANALYTICS_GTAG=G-1234567890 GOOGLE_ANALYTICS_GTAG=G-1234567890
SITE_URL=http://localhost SITE_URL=http://localhost
PORT=4321
WEBHOOK_PORT=3210
STRAPI_URL="http://localhost:1337"
STRAPI_API_TOKEN=...
SQUIDEX_APP_NAME=
SQUIDEX_CLIENT_ID=
SQUIDEX_CLIENT_SECRET=
SQUIDEX_ENVIRONMENT=http://<internal-api-host>
SQUIDEX_PUBLIC_URL=https://<public-squidex-host>/

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
# VS Code setting folder
.vscode/

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: "node-modules"

45
Dockerfile Normal file
View File

@ -0,0 +1,45 @@
FROM node:lts AS core
WORKDIR /opt/app
FROM core AS base
#COPY package.json package-lock.json ./
#workaround: https://github.com/npm/cli/issues/4828
RUN apt update && apt upgrade -y
RUN npm install -g npm@latest
RUN npm install -g pnpm
COPY package.json package-lock.json .yarnrc.yml ./
#FROM base AS prod-deps
#RUN pnpm install --fix-lockfile
#RUN pnpm install --lockfile-only
#RUN pnpm install --prod
#RUN npm install --omit=dev
FROM base AS build-deps
RUN pnpm install --fix-lockfile
RUN pnpm install --lockfile-only
RUN pnpm install
#RUN npm install
FROM build-deps AS copy
COPY . .
FROM copy AS build
RUN pnpm run astro build
# RUN npm run build
#RUN npm run-script astro build
FROM base AS runtime
#COPY --from=prod-deps /opt/app/node_modules ./node_modules
COPY --from=build-deps /opt/app/node_modules ./node_modules
COPY --from=copy /opt/app .
COPY --from=build /opt/app/dist ./dist
ENV HOST=0.0.0.0
ENV PORT=4321
ENV WEBHOOK_PORT=3210
EXPOSE 4321 3210
CMD pnpm run server
#CMD bash

View File

@ -1,35 +1,65 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import { loadEnv } from "vite"; import { loadEnv } from "vite";
import { ALL_PRODUCTS } from './src/data/products'; import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
import react from "@astrojs/react"; import react from "@astrojs/react";
import commonjs from 'vite-plugin-commonjs'
const { const {
SITE_URL SITE_URL
} = loadEnv(process.env.NODE_ENV, process.cwd(), ""); } = loadEnv(process.env.NODE_ENV, process.cwd(), "");
function generateRedirectsForAmazonProductIds() {
let redirects = {}; // this was used for static generation, but now we're using SSR
for (let p = 0; p < ALL_PRODUCTS.length; p++) { // import { ALL_PRODUCTS } from './src/data/products';
let product = ALL_PRODUCTS[p]; // function generateRedirectsForAmazonProductIds() {
if (product.amazonProductId && product.slug !== product.amazonProductId) { // let redirects = {};
redirects[`/${product.amazonProductId}`] = `/${product.slug}`; // for (let p = 0; p < ALL_PRODUCTS.length; p++) {
} // let product = ALL_PRODUCTS[p];
} // if (product.ASIN && product.slug !== product.ASIN) {
return redirects; // redirects[`/${product.ASIN}`] = `/${product.slug}`;
} // }
// }
// return redirects;
// }
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: SITE_URL || 'http://localhost', site: SITE_URL || 'http://localhost',
integrations: [sitemap(), react()], integrations: [sitemap(), react()],
redirects: generateRedirectsForAmazonProductIds() // redirects: generateRedirectsForAmazonProductIds(),
// vite: { output: 'server',
server: {
host: '0.0.0.0',
},
// i18n: {
// defaultLocale: 'en',
// locales: ['en', 'es', 'fr'],
// },
adapter: node({
mode: 'middleware',
}),
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
rollupOptions: {
external: ['@squidex/squidex', '../squidex-node-sdk'],
}
},
vite: {
plugins: [
commonjs(/**/)
],
optimizeDeps: {
exclude: ['@squidex/squidex', '../squidex-node-sdk'],
}
// resolve: { // resolve: {
// alias: [ // alias: [
// { find: /^swiper\/(.+)/, replacement: 'swiper/$1 '}, // { find: /^swiper\/(.+)/, replacement: 'swiper/$1 '},
// ], // ],
// }, // },
// }, },
// experimental: { // experimental: {
// resolveId: (id) => { // resolveId: (id) => {
// if (id === 'swiper') { // if (id === 'swiper') {

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
version: "3"
services:
dashersupply-app:
container_name: dashersupply-app
build: .
image: runtime:latest
restart: unless-stopped
env_file: .env
environment:
SITE_URL: ${SITE_URL}
PORT: ${PORT}
WEBHOOK_PORT: ${WEBHOOK_PORT}
STRAPI_URL: ${STRAPI_URL}
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
NODE_ENV: ${NODE_ENV}
#volumes:
# - dashersupply-app:/opt/app
ports:
- "4321:4321"
- "3210:3210"
networks:
- dashersupply
# depends_on:
# - dashersupply-strapi
volumes:
dashersupply-app:
networks:
dashersupply:
name: Dasher Supply
driver: bridge

6937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,63 @@
{ {
"name": "dashersupply", "name": "dashersupply",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.2.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"test": "vitest" "test": "vitest",
"ts-node": "node --loader ts-node",
"server": "node --loader ts-node/esm run-server.mts",
"webhooks": "bun run src/apps/squidex-webhooks.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.8.1", "@astrojs/check": "^0.9.3",
"@astrojs/react": "^3.6.0", "@astrojs/node": "^8.3.3",
"@astrojs/react": "^3.6.2",
"@astrojs/sitemap": "^3.1.6", "@astrojs/sitemap": "^3.1.6",
"@fastify/middie": "^8.3.1",
"@fastify/static": "^7.0.4",
"@squidex/squidex": "^1.2.1",
"@strapi/blocks-react-renderer": "^1.0.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"amazon-pa-api5-node-ts": "^2.1.4", "amazon-pa-api5-node-ts": "^2.3.0",
"astro": "^4.11.5", "astro": "^4.14.2",
"axios": "^1.7.4",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"cheerio": "*", "cheerio": "*",
"commander": "^12.1.0",
"crawlee": "^3.0.0", "crawlee": "^3.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6", "dotenv-expand": "^11.0.6",
"fastify": "^4.28.1",
"luxon": "^3.5.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"markdown-it-attrs": "^4.1.6", "markdown-it-attrs": "^4.1.6",
"memfs": "^4.11.1",
"multer": "^1.4.5-lts.1",
"ollama": "^0.5.8",
"playwright": "*", "playwright": "*",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"slugify": "^1.6.6",
"swiper": "^11.1.4", "swiper": "^11.1.4",
"vite": "^5.3.5",
"vite-plugin-commonjs": "^0.10.1",
"vitest": "^2.0.3" "vitest": "^2.0.3"
}, },
"devDependencies": { "devDependencies": {
"@apify/tsconfig": "^0.1.0", "@apify/tsconfig": "^0.1.0",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.30",
"@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@types/markdown-it-attrs": "^4.1.3", "@types/markdown-it-attrs": "^4.1.3",
"@types/multer": "^1.4.11",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.4.0", "tsx": "^4.4.0",
"typescript": "^5.5.3" "typescript": "^5.5.3"
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

21
run-server.mts Normal file
View File

@ -0,0 +1,21 @@
import Fastify from 'fastify';
import fastifyMiddie from '@fastify/middie';
import fastifyStatic from '@fastify/static';
import { fileURLToPath } from 'node:url';
import { handler as ssrHandler } from './dist/server/entry.mjs';
import { config } from './src/config.ts';
const app = Fastify({ logger: true });
await app
.register(fastifyStatic, {
root: fileURLToPath(new URL('./dist/client', import.meta.url)),
})
.register(fastifyMiddie);
app.use(ssrHandler);
app.listen({ host: '0.0.0.0', port: config.port }, (err, address) => {
if (err) {
app.log.error(err);
process.exit(1);
}
app.log.info(`Fastify web server with Astro middleware listening at ${address}.`)
});

86
src/apiclient/amazon.ts Normal file
View File

@ -0,0 +1,86 @@
import { GetItemsResourceValues } from 'amazon-pa-api5-node-ts/src/model/GetItemsResource.mts';
// import { amazonPAAPIConfig } from '../data/fetch-site';
// import { config } from '../config';
import { getSiteConfig } from '../data/api-client';
import { client } from '../data/core/client';
import * as ProductAdvertisingAPIv1 from 'amazon-pa-api5-node-ts';
// import type { AmazonProductDetails } from '../data/products/amazon-product-details';
import { title } from 'process';
const siteConfig = await getSiteConfig();
const defaultClient = ProductAdvertisingAPIv1.ApiClient.instance;
defaultClient.accessKey = siteConfig.items[0].data?.amazonPAAPIConfig.iv.accessKey!;
defaultClient.secretKey = siteConfig.items[0].data?.amazonPAAPIConfig.iv.secretKey!;
defaultClient.host = siteConfig.items[0].data?.amazonPAAPIConfig.iv.host!;
defaultClient.region = siteConfig.items[0].data?.amazonPAAPIConfig.iv.region!;
const api = new ProductAdvertisingAPIv1.DefaultApi();
export const getItems = (itemIds: string[]): Promise<ProductAdvertisingAPIv1.GetItemsResponse> => {
if (itemIds.length < 1 || itemIds.length > 10) {
throw new Error("itemIds must be between 1 and 10.");
}
const getItemsRequest = new ProductAdvertisingAPIv1.GetItemsRequest();
getItemsRequest['PartnerTag'] = siteConfig.items[0].data?.amazonPAAPIConfig.iv.partnerTag;
getItemsRequest['PartnerType'] = siteConfig.items[0].data?.amazonPAAPIConfig.iv.partnerType;
getItemsRequest['ItemIds'] = itemIds;
getItemsRequest['Condition'] = 'New';
getItemsRequest['Resources'] = [
GetItemsResourceValues['Images.Primary.Large'],
GetItemsResourceValues['Images.Variants.Large'],
GetItemsResourceValues['Offers.Listings.Price'],
GetItemsResourceValues['ItemInfo.Title'],
GetItemsResourceValues['ItemInfo.Features'],
GetItemsResourceValues['CustomerReviews.Count'],
GetItemsResourceValues['CustomerReviews.StarRating'],
GetItemsResourceValues['ItemInfo.ProductInfo']
];
return new Promise<ProductAdvertisingAPIv1.GetItemsResponse>((resolve, reject) => {
return api.getItems(getItemsRequest).then(
function(data) {
console.log('API called successfully.');
const getItemsResponse = ProductAdvertisingAPIv1.GetItemsResponse.constructFromObject(data)!;
console.log('Complete Response: \n' + JSON.stringify(getItemsResponse, null, 1));
if (getItemsResponse['Errors'] !== undefined) {
//TODO: Write failure story to Squidex about Amazon PA API call (attach JSON).
console.error('\nErrors:');
console.error('Complete Error Response: ' + JSON.stringify(getItemsResponse['Errors'], null, 1));
console.error('Printing 1st Error:');
var error_0 = getItemsResponse['Errors'][0];
console.error('Error Code: ' + error_0['Code']);
console.error('Error Message: ' + error_0['Message']);
reject(`Error ${error_0['Code']}: ${error_0['Message']}`);
}
else {
//TODO: Write success story to Squidex about Amazon PA API call (attach JSON).
// let parsedItems: ProductAdvertisingAPIv1.GetItemsResponse[] = [];
// for (let item of getItemsResponse.ItemsResult?.Items!) {
// parsedItems.push({
// ASIN: item.ASIN!,
// amazonLink: item.DetailPageURL!,
// featureBullets: item.ItemInfo?.Features?.DisplayValues!,
// // description: ,
// title: item.ItemInfo?.Title?.DisplayValue!,
// })
// }
// resolve(parsedItems);
resolve(data);
}
},
function(error) {
console.log('Error calling PA-API 5.0!');
console.log('Printing Full Error Object:\n' + JSON.stringify(error, null, 1));
console.log('Status Code: ' + error['status']);
if (error['response'] !== undefined && error['response']['text'] !== undefined) {
console.log('Error Object: ' + JSON.stringify(error['response']['text'], null, 1));
}
}
);
});
}

View File

@ -0,0 +1,104 @@
import { Assets } from "@squidex/squidex/api/resources/assets/client/Client.js"
import { SquidexClient } from "@squidex/squidex"
import * as environments from "@squidex/squidex/environments.js";
import * as core from "@squidex/squidex/core/index.js";
import { Squidex } from "@squidex/squidex";
import urlJoin from "url-join";
import * as errors from "@squidex/squidex/errors/index.js";
import * as serializers from "@squidex/squidex/serialization/index.js";
import * as fs from "fs";
import { default as FormData } from "form-data";
/**
* You can only upload one file at a time. The mime type of the file is not calculated by Squidex and is required correctly.
* @throws {@link Squidex.BadRequestError}
* @throws {@link Squidex.NotFoundError}
* @throws {@link Squidex.ContentTooLargeError}
* @throws {@link Squidex.InternalServerError}
*/
export function async customPostAsset(
file: File | fs.ReadStream,
requestOptions?: Assets.RequestOptions
): Promise<Squidex.AssetDto> {
const _request = new FormData();
_request.append("file", file);
const _response = await (this._options.fetcher ?? core.fetcher)({
url: urlJoin(
(await core.Supplier.get(this._options.environment)) ?? environments.SquidexEnvironment.Default,
`api/apps/${this._options.appName}/assets`
),
method: "POST",
headers: {
Authorization: await this._getAuthorizationHeader(),
"X-Fern-Language": "JavaScript",
"X-Fern-SDK-Name": "@squidex/squidex",
"X-Fern-SDK-Version": "1.2.1",
"Content-Length": (await core.getFormDataContentLength(_request)).toString(),
},
contentType: "multipart/form-data; boundary=" + _request.getBoundary(),
body: _request,
timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 60000,
});
if (_response.ok) {
return await serializers.AssetDto.parseOrThrow(_response.body, {
unrecognizedObjectKeys: "passthrough",
allowUnrecognizedUnionMembers: true,
allowUnrecognizedEnumValues: true,
breadcrumbsPrefix: ["response"],
});
}
if (_response.error.reason === "status-code") {
switch (_response.error.statusCode) {
case 400:
throw new Squidex.BadRequestError(
await serializers.ErrorDto.parseOrThrow(_response.error.body, {
unrecognizedObjectKeys: "passthrough",
allowUnrecognizedUnionMembers: true,
allowUnrecognizedEnumValues: true,
breadcrumbsPrefix: ["response"],
})
);
case 404:
throw new Squidex.NotFoundError(_response.error.body);
case 413:
throw new Squidex.ContentTooLargeError(
await serializers.ErrorDto.parseOrThrow(_response.error.body, {
unrecognizedObjectKeys: "passthrough",
allowUnrecognizedUnionMembers: true,
allowUnrecognizedEnumValues: true,
breadcrumbsPrefix: ["response"],
})
);
case 500:
throw new Squidex.InternalServerError(
await serializers.ErrorDto.parseOrThrow(_response.error.body, {
unrecognizedObjectKeys: "passthrough",
allowUnrecognizedUnionMembers: true,
allowUnrecognizedEnumValues: true,
breadcrumbsPrefix: ["response"],
})
);
default:
throw new errors.SquidexError({
statusCode: _response.error.statusCode,
body: _response.error.body,
});
}
}
switch (_response.error.reason) {
case "non-json":
throw new errors.SquidexError({
statusCode: _response.error.statusCode,
body: _response.error.rawBody,
});
case "timeout":
throw new errors.SquidexTimeoutError();
case "unknown":
throw new errors.SquidexError({
message: _response.error.errorMessage,
});
}
};

403
src/apps/amazon-catalog.ts Normal file
View File

@ -0,0 +1,403 @@
import { getBrandsByLangSlug, getBrandsUsingJsonQuery, getMarketplacesUsingJsonQuery, getProductCategoriesByIds, getProductCategoriesUsingJsonQuery, getProductListingsUsingJsonQuery, getProductsByIds, getProductsUsingJsonQuery, getSellersUsingJsonQuery, performSyncLocalizedSlugs } from '../data/api-client.ts';
import { program } from 'commander';
import * as core from '../data/core/client.js'
import type { AmazonMarketplaceConnection } from '../data/models/components/AmazonMarketplaceConnection.js';
import type { Product } from '../data/models/multis/Product.js';
import type { Listing } from '../data/models/multis/Listing.js';
import type { Brand } from '../data/models/multis/Brand.js';
import { askAiProductSubCategoryEvalQuestionsSet1, askAiProductSubCategoryEvalQuestionsSet2, askAiTopLevelProductCategoryEvalQuestions, designAiTopLevelProductCategoryEvalQuestions, doesProductAlreadyExist, generateAIProductDescription, generateAITagsForProduct, getAddNewBrandDtoByName, getAddNewProductDtoByProduct, getAddNewProductListingDtoByProduct, getAddNewProductSubCategoryDto, getAddNewSellerDtoByName, getAllTopLevelProductCategories, getAmazonMarketplaceConnectionSchemaDto, getAmazonMarketplaceDto, getBrandDtoByName, getMarketplaceConnectionSchemaDto, getSellerDtoByName, isValidASIN, removeQuotes, uploadDownloadedImageToSquidexAsAsset, translateAmazonDescription_from_en_US_to_es_US, translateAmazonDescription_from_en_US_to_fr_CA, translateProductDescription_from_en_US_to_es_US, translateProductName_from_en_US_to_es_US, translateProductName_from_en_US_to_fr_CA, translateTags_from_en_US_to_es_US, translateTags_from_en_US_to_fr_CA, trimPeriods, upsertAssetFolder, getAllAssetsInFolder, getAddNewOfferDto, lookupAmazonASINs, translateProductDescription_from_en_US_to_fr_CA } from './modules/amazon-catalog-helpers.js';
import slugify from 'slugify';
import type { Multilingual } from '../data/internals/MultilingualT.js';
import type { NonMultilingual } from '../data/internals/NonMultilingualT.js';
import type { MarketplaceConnection } from '../data/models/components/MarketplaceConnection.js';
import type { Offer } from '../data/models/multis/Offer.js';
import { SCHEMAS } from '../data/models/schemas.js';
import { CheerioCrawler, type CheerioCrawlingContext, log } from 'crawlee';
import { extractProductDetails } from '../scraper/amazon.js';
program.command('ls')
.description('List all the products')
.action(async (args: string[]) => {
let productsDto = await getProductsUsingJsonQuery();
productsDto.items.forEach((productDto, index) => {
console.log(`[ls:${index+1}] ID: ${productDto.id}`)
console.log(`[ls:${index+1}] Product (en-US): ${productDto.data?.productName['en-US']}`);
console.log(`[ls:${index+1}] Product (es-US): ${productDto.data?.productName['es-US']}`);
console.log(`[ls:${index+1}] Product (fr-CA): ${productDto.data?.productName['fr-CA']}`);
let asin = productDto.data?.marketplaceConnections.iv.map((connection) => (connection.connection as AmazonMarketplaceConnection).asin).join('');
let siteStripUrl = productDto.data?.marketplaceConnections.iv.map((connection) => (connection.connection as AmazonMarketplaceConnection).siteStripeUrl).join('');
console.log(`[ls:${index+1}] ASIN: ${asin} ${!!(asin||'').match(/[A-Z0-9]{10}/g) ? 'Is a valid ASIN.' : 'Is not a valid ASIN.'}`)
console.log(`[ls:${index+1}] Amazon SiteStripe URL: ${siteStripUrl}`)
console.log();
});
console.log(`[ls] Returned ${productsDto.items.length} products.`)
})
.configureHelp();
program.command('crawlee-products')
.alias('crawlee-product')
.argument('<URLs...>')
.description('Attempts to crawl Amazon product data using crawlee')
.action(async (urls: string[]) => {
/**
* 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);
};
/**
* 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(urls);
})
.configureHelp();
program.command('sync-slugs')
.description('Sync URL slugs for each frontend endpoint.')
.action(async (asin: string, args: string[]) => {
await performSyncLocalizedSlugs(console.log);
})
.configureHelp();
program.command('append-images')
.alias('append-image')
.argument('<ASIN>', 'Amazon Standard Identification Numbers')
.argument('<URLs...>', 'Image URLs to transmit to the database.')
.description('Download images from URL .')
.action(async (asin: string, urls: string[]) => {
if (!isValidASIN(asin)) {
console.error(`[append-images] error: ${asin} is not a valid ASIN. Amazon Standard Identification Numbers are 10-digits long with letters A-Z and numbers 0-9.`);
return;
}
let productsDto = await getProductsUsingJsonQuery(JSON.stringify({ filter: {
op: 'eq',
path: 'data.marketplaceConnections.iv.connection.asin',
value: asin,
}}));
if (productsDto.items.length === 0) {
console.error(`[append-images] error: ${asin} was not found in the database. Please procure or enter the product first.`);
return;
}
let marketplacesDto = await getMarketplacesUsingJsonQuery(JSON.stringify({ filter: {
op: 'eq',
path: 'data.marketplaceName.en-US',
value: 'Amazon',
}}));
if (marketplacesDto.items.length === 0) {
console.error(`[append-images] error: Amazon marketplace not found in database. Please set up Amazon marketplace in database.`);
return;
}
console.log(`[append-images] Upserting Asset Folder products`);
let productsAssetFolder = await upsertAssetFolder('products');
console.log(`[append-images] Matching Asset Folder: ${productsAssetFolder.folderName} with Asset Folder ID: ${productsAssetFolder.id}`);
console.log(`[append-images] Upserting Asset Folder ${productsAssetFolder.folderName}/amazon`);
let productsAmazonAssetFolder = await upsertAssetFolder('amazon', productsAssetFolder.id);
console.log(`[append-images] Matching Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName} with Asset Folder ID: ${productsAmazonAssetFolder.id}`);
console.log(`[append-images] Upserting Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName}/${asin}`);
let productsASINFolder = await upsertAssetFolder(asin, productsAmazonAssetFolder.id);
console.log(`[append-images] Matching Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName}/${productsASINFolder.folderName} with Asset Folder ID: ${productsASINFolder.id}`);
let amazonMarketplaceDto = marketplacesDto.items[0];
for (let productDto of productsDto.items) {
let listingsDto = await getProductListingsUsingJsonQuery(JSON.stringify({ filter: {
'and': [
{
op: 'eq',
path: 'data.product.iv',
value: productDto.id,
},
{
op: 'eq',
path: 'data.marketplace.iv',
value: amazonMarketplaceDto.id,
}
]
}}));
if (listingsDto.items.length === 0) {
console.error(`[append-images] error: Product listing for ${asin} on Amazon marketplace was not found in the database. Please procure or enter the product listing first.`);
return;
}
for (let listingDto of listingsDto.items) {
let listing = listingDto.data!;
let didUpdate = false;
let amazonAssetsDto = await getAllAssetsInFolder(productsASINFolder.id);
for (let i = 0; i < urls.length; i++) {
console.log(urls[i]);
let foundUploaded = amazonAssetsDto.filter((asset) => (asset.metadata['amazon-url'] as string||'') === urls[i]);
if (!foundUploaded.length) { // is not found
console.log(`[append-images] Transmitting Product Image ${urls[i]} to Squidex Assets`);
let assetDto = await uploadDownloadedImageToSquidexAsAsset(urls[i]!, productsASINFolder.id);
console.log(`[append-images] Saved Asset Id: ${assetDto.id} to Asset Folder Id: ${assetDto.parentId}`);
if (!listing.marketplaceImages) {
listing.marketplaceImages = { iv: [] };
}
if (!listing.marketplaceImages.iv) {
listing.marketplaceImages.iv = [];
}
listing.marketplaceImages.iv.push(assetDto.id);
didUpdate = true;
}
else { // is found
console.log(`[append-images] Matched Asset Id: ${foundUploaded[0].id}, Amazon Product Image: ${foundUploaded[0].metadata['amazon-url'] as string||''}.`);
}
}
if (didUpdate) {
console.log(`[append-images] Listing did update, updating product listing with appended images.`);
let updatedDto = await core.client.contents.putContent(SCHEMAS.LISTINGS, listingDto.id, {
unpublished: false,
body: listing as any,
}, {
timeoutInSeconds: core.TIMEOUT_IN_SECONDS
});
console.log(`[append-images] Listing version ${updatedDto.version} stored.`);
}
}
}
})
.configureHelp();
program.command('procure-asins')
.alias('procure-asin')
.argument('<ASINs...>', 'Amazon Standard Identification Numbers')
.description('Begin automated product procurement from Amazon by inputting one or more ASINs separated by spaces.')
.action(async (asins: string[]) => {
if (!asins.length) {
console.error(`[procure-asin] error: You must specify one or more valid ASINs. Amazon Standard Identification Numbers are 10-digits long with letters A-Z and numbers 0-9.`);
return;
}
for (let a = 0; a < asins.length; a++) {
let asin = asins[a].toUpperCase();
if (!isValidASIN(asin)) {
console.error(`[procure-asin] error: ${asin} is not a valid ASIN. Amazon Standard Identification Numbers are 10-digits long with letters A-Z and numbers 0-9.`);
return;
}
}
console.log(`[procure-asin] You started product enrollment for ${asins.length} items.`);
if (asins.length > 10) {
console.log(`[procure-asin] PA API calls will be broken up into 10 items per Amazon API request.`);
}
const MAX_ITEMS_PER_API_REQUEST = 10;
for (let a = 0; a < asins.length; a += MAX_ITEMS_PER_API_REQUEST) {
let asinsToRequest = asins.slice(a, a + MAX_ITEMS_PER_API_REQUEST).map(asin => asin = asin.toLowerCase());
console.log(`[procure-asin] Begin product enrollment(s) for ${asins.length} ASINs ${asinsToRequest.join(' ')}.`);
//get Amazon data ; we will use previously obtained data for the testing phase
//TODO:remove these three lines and replace with code that reads the Amazon PA API
// console.log(`[procure-asin] NOTICE: For testing purposes, using data from stored previous API response.`);
console.log(`[procure-asin] NOTICE: Production Amazon PA API request in progress.`);
const responseDto = await lookupAmazonASINs(asinsToRequest);
const apiResponse = responseDto.items[0].data?.apiResponse.iv!;
apiResponse.ItemsResult!.Items!.forEach(async (amazonItem) => {
let asin = amazonItem.ASIN!;
console.log(`[procure-asin] Mock data override: product enrollment is for ASIN ${asin}.`);
// enable this if we're saving data:
// if (await doesProductAlreadyExist(asin)) {
// console.error(`[procure-asin] error: Product with ASIN already exists in the application.`);
// return;
// }
console.log(`[procure-asin] Amazon PA API Response contains ASIN: ${asin}.`);
const amazonProductName_en_US = amazonItem.ItemInfo?.Title?.DisplayValue!;
console.log(`[procure-asin] Amazon PA API Response contains Product Name: ${amazonProductName_en_US}.`);
console.log(`[procure-asin] Amazon PA API Response contains Product Features: \n${amazonItem.ItemInfo?.Features?.DisplayValues?.map((item) => `- ${item}`).join('\n')}`);
const amazonProductImages = [amazonItem.Images?.Primary?.Large?.URL, ...(amazonItem.Images?.Variants?.map(variant => variant.Large?.URL)||[])];
console.log(`[procure-asin] Amazon PA API Response contains Product Images: \n${amazonProductImages.map((url, index) => `${index+1}. ${url}`).join('\n')}`);
console.log(`[procure-asin] Upserting Asset Folder products`);
let productsAssetFolder = await upsertAssetFolder('products');
console.log(`[procure-asin] Matching Asset Folder: ${productsAssetFolder.folderName} with Asset Folder ID: ${productsAssetFolder.id}`);
console.log(`[procure-asin] Upserting Asset Folder ${productsAssetFolder.folderName}/amazon`);
let productsAmazonAssetFolder = await upsertAssetFolder('amazon', productsAssetFolder.id);
console.log(`[procure-asin] Matching Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName} with Asset Folder ID: ${productsAmazonAssetFolder.id}`);
console.log(`[procure-asin] Upserting Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName}/${asin}`);
let productsASINFolder = await upsertAssetFolder(asin, productsAmazonAssetFolder.id);
console.log(`[procure-asin] Matching Asset Folder ${productsAssetFolder.folderName}/${productsAmazonAssetFolder.folderName}/${productsASINFolder.folderName} with Asset Folder ID: ${productsASINFolder.id}`);
let amazonAssetsDto = await getAllAssetsInFolder(productsASINFolder.id);
let amazonAssetsIds = amazonAssetsDto.map(asset => asset.id);
let amazonAssetsUrls: string[] = amazonAssetsDto.map(asset => asset.metadata['amazon-url'] as string||'').filter(url => url);
for (let i = 0; i < amazonProductImages.length; i++) {
let foundUploaded = amazonAssetsDto.filter((asset) => (asset.metadata['amazon-url'] as string||'') === amazonProductImages[i]);
if (!foundUploaded.length) { // is not found
console.log(`[procure-asin] Transmitting Amazon Product Image ${amazonProductImages[i]} to Squidex Assets`);
let assetDto = await uploadDownloadedImageToSquidexAsAsset(amazonProductImages[i]!, productsASINFolder.id);
console.log(`[procure-asin] Saved Asset Id: ${assetDto.id} to Asset Folder Id: ${assetDto.parentId}`);
amazonAssetsDto.push(assetDto);
amazonAssetsIds.push(assetDto.id);
amazonAssetsUrls.push(assetDto.metadata['amazon-url'] as string||'');
}
else { // is found
console.log(`[procure-asin] Matched Asset Id: ${foundUploaded[0].id}, Amazon Product Image: ${foundUploaded[0].metadata['amazon-url'] as string||''}.`);
}
}
let schemaProductMarketplaceConnectionDto = await getMarketplaceConnectionSchemaDto();
console.log(`[procure-asin] Matching Schema ID: ${schemaProductMarketplaceConnectionDto.id}, Schema Name: ${schemaProductMarketplaceConnectionDto.name}.`);
let schemaAmazonMarketplaceConnectionDto = await getAmazonMarketplaceConnectionSchemaDto();
console.log(`[procure-asin] Matching Schema ID: ${schemaAmazonMarketplaceConnectionDto.id}, Schema Name: ${schemaAmazonMarketplaceConnectionDto.name}.`);
let amazonMarketplaceDto = await getAmazonMarketplaceDto()
console.log(`[procure-asin] Matching Marketplace ID: ${amazonMarketplaceDto.items[0].id}, Marketplace Name: ${amazonMarketplaceDto.items[0].data?.marketplaceName['en-US']}.`);
let bylineBrand = amazonItem.ItemInfo!.ByLineInfo!.Brand!.DisplayValue!;
console.log(`[procure-asin] Amazon PA API Response contains Brand ${bylineBrand}. Checking database for brand...`);
let brandsDto = await getBrandDtoByName(bylineBrand);
if (!brandsDto.items.length) {
console.log(`[procure-asin] Brand not found in database. Product requires creation of brand first.`);
brandsDto = await getAddNewBrandDtoByName(bylineBrand);
console.log(`[procure-asin] Brand Id: ${brandsDto.items[0].id}, Brand Name: ${brandsDto.items[0].data?.brandName['en-US']} created in database.`);
} else {
console.log(`[procure-asin] Matching Brand ID: ${brandsDto.items[0].id}, Brand Name: ${brandsDto.items[0].data?.brandName['en-US']}.`);
}
let brandName = brandsDto.items[0].data?.brandName['en-US']!;
let features = amazonItem.ItemInfo?.Features?.DisplayValues!;
console.log(`[procure-asin] Amazon Product Name (en-US): ${amazonProductName_en_US}`);
console.log(`[procure-asin] Requesting llama3.1 LLM to translate product name from en-US to es-US.`);
const amazonProductName_es_US = await translateProductName_from_en_US_to_es_US(amazonItem, brandName, amazonProductName_en_US);
console.log(`[procure-asin] AI Product Name (es-US): ${amazonProductName_es_US}`);
console.log(`[procure-asin] Requesting llama3.1 LLM to translate product name from en-US to fr-CA.`);
const amazonProductName_fr_CA = await translateProductName_from_en_US_to_fr_CA(amazonItem, brandName, amazonProductName_en_US);
console.log(`[procure-asin] AI Product Name (fr-CA): ${amazonProductName_fr_CA}`);
const aiProductDescriptionResponse_en_US = await generateAIProductDescription(amazonProductName_en_US, features);
console.log(`[procure-asin] Generated AI Product Description (en-US):\n${aiProductDescriptionResponse_en_US}`);
console.log(`[procure-asin] Requesting llama3.1 LLM to translate product description from en-US to es-US.`);
const aiProductDescriptionResponse_es_US = await translateProductDescription_from_en_US_to_es_US(brandName, aiProductDescriptionResponse_en_US);
console.log(`[procure-asin] Translated AI Product Description (es-US):\n${aiProductDescriptionResponse_es_US}`);
console.log(`[procure-asin] Requesting llama3.1 LLM to translate product description from en-US to fr-CA.`);
const aiProductDescriptionResponse_fr_CA = await translateProductDescription_from_en_US_to_fr_CA(brandName, aiProductDescriptionResponse_en_US);
console.log(`[procure-asin] AI Product Description (fr-CA):\n${aiProductDescriptionResponse_fr_CA}`);
const aiTopLevelCategoryChoice = await askAiTopLevelProductCategoryEvalQuestions(brandName, amazonProductName_en_US);
console.log('[procure-asin]', `AI chooses to put product within top level Product Category ID: ${aiTopLevelCategoryChoice}, Product Category Name: ${(await getAllTopLevelProductCategories()).filter((category) => category.id === aiTopLevelCategoryChoice)[0].categoryName!['en-US']}`);
const aiSubCategoryQuestion1Answer = await askAiProductSubCategoryEvalQuestionsSet1(aiTopLevelCategoryChoice, brandName, amazonProductName_en_US, features);
console.log('[procure-asin]', `AI then chooses within the top-level category to ${aiSubCategoryQuestion1Answer?'make a new sub-category':'use an existing sub-category'}.`);
const aiSubCategoryQuestion2Answer = await askAiProductSubCategoryEvalQuestionsSet2(apiResponse, aiTopLevelCategoryChoice, brandName, amazonProductName_en_US, features, aiSubCategoryQuestion1Answer);;
let aiProductCategoriesDto;
if (!aiSubCategoryQuestion1Answer) {
let aiSubCategoryQuestion2AnswerStr = aiSubCategoryQuestion2Answer as string;
console.log(`[procure-asin] AI suggested existing sub-category ${aiSubCategoryQuestion2Answer}`);
aiProductCategoriesDto = await getProductCategoriesByIds(aiSubCategoryQuestion2AnswerStr);
}
else {
let aiSubCategoryQuestion2AnswerObj = aiSubCategoryQuestion2Answer as {
categoryName: Multilingual<string>,
description: Multilingual<string>,
parentCategory: NonMultilingual<string[]>,
};
console.log(`[procure-asin] AI suggested new sub-category ${aiSubCategoryQuestion2AnswerObj.categoryName['en-US']}`);
aiProductCategoriesDto = await getAddNewProductSubCategoryDto({ iv: [aiTopLevelCategoryChoice] }, aiSubCategoryQuestion2AnswerObj.categoryName, aiSubCategoryQuestion2AnswerObj.description);
}
let aiProductTags_en_US = await generateAITagsForProduct(amazonProductName_en_US, features);
let aiProductTags_es_US = await translateTags_from_en_US_to_es_US(aiProductTags_en_US);
let aiProductTags_fr_CA = await translateTags_from_en_US_to_fr_CA(aiProductTags_en_US);
let product: Product = {
brand: { iv: [brandsDto.items![0].id!] },
categories: { iv: aiProductCategoriesDto.items.map(category => category.id) },
productName: {
"en-US": amazonProductName_en_US,
"es-US": amazonProductName_es_US,
"fr-CA": amazonProductName_fr_CA,
},
slug: {
"en-US": `${brandsDto.items![0].data?.slug['en-US']}/${slugify(trimPeriods(removeQuotes(amazonProductName_en_US)), { lower: true, trim: true })}`,
"es-US": `${brandsDto.items![0].data?.slug['es-US']}/${slugify(trimPeriods(removeQuotes(amazonProductName_es_US)), { lower: true, trim: true })}`,
"fr-CA": `${brandsDto.items![0].data?.slug['fr-CA']}/${slugify(trimPeriods(removeQuotes(amazonProductName_fr_CA)), { lower: true, trim: true })}`,
},
description: {
"en-US": aiProductDescriptionResponse_en_US,
"es-US": aiProductDescriptionResponse_es_US,
"fr-CA": aiProductDescriptionResponse_fr_CA,
},
tags: {
"en-US": aiProductTags_en_US,
"es-US": aiProductTags_es_US,
"fr-CA": aiProductTags_fr_CA,
},
marketplaceConnections: {
iv: [
{
schemaId: schemaProductMarketplaceConnectionDto.id,
marketplace: [amazonMarketplaceDto.items[0].id],
connection: {
schemaId: schemaAmazonMarketplaceConnectionDto.id,
asin,
siteStripeUrl: amazonItem.DetailPageURL,
} as AmazonMarketplaceConnection | MarketplaceConnection
}
]
}
};
console.log(`[procure-asin] New product to store:`, product);
let productsDto = await getAddNewProductDtoByProduct(product);
console.log(`[procure-asin] Product Id: ${productsDto.items[0].id}, Product Name: ${productsDto.items[0].data?.productName['en-US']} created in database.`);
let listing: Listing = {
marketplace: { iv: [ amazonMarketplaceDto.items[0].id ] },
product: { iv: [ productsDto.items[0].id ]},
marketplaceDescription: {
"en-US": `${features.map((feature) => `- ${feature}`).join('\n')}\n`,
"es-US": await translateAmazonDescription_from_en_US_to_es_US(brandName, features),
"fr-CA": await translateAmazonDescription_from_en_US_to_fr_CA(brandName, features),
},
marketplaceImages: { iv: amazonAssetsIds },
};
console.log(`[procure-asin] New product listing to store:`, listing);
let listingsDto = await getAddNewProductListingDtoByProduct(listing);
console.log(`[procure-asin] Product Listing Id: ${listingsDto.items[0].id}, Product Id: ${listingsDto.items[0].data?.product.iv[0]}, Marketplace Id: ${listingsDto.items[0].data?.marketplace.iv[0]} created in database.`);
amazonItem.Offers?.Listings?.forEach(async (amazonListing) => {
console.log(`[procure-asin] Amazon PA API Response contains Seller ${amazonListing.MerchantInfo?.Name} offer for ${amazonListing.Price?.Amount} in ${amazonListing.Condition?.Value} condition. Checking database for seller...`);
let sellersDto = await getSellerDtoByName(amazonListing.MerchantInfo!.Name!);
if (!sellersDto.items.length) {
console.log(`[procure-asin] Seller not found in database. Listing requires creation of seller first.`);
sellersDto = await getAddNewSellerDtoByName(amazonListing.MerchantInfo!.Name!);
console.log(`[procure-asin] Seller Id: ${sellersDto.items[0].id}, Seller Name: ${sellersDto.items[0].data?.sellerName['en-US']} created in database.`);
} else {
console.log(`[procure-asin] Matching Seller ID: ${sellersDto.items[0].id}, Seller Name: ${sellersDto.items[0].data?.sellerName['en-US']}.`);
}
let offer: Offer = {
offerDate: { iv: new Date().toISOString() },
listing: { iv: [ listingsDto.items[0].id ] },
seller: { iv: [ sellersDto.items[0].id ] },
newPrice: { iv: null },
usedPrice: { iv: null },
};
if (amazonListing.Condition?.Value === 'New') {
offer.newPrice = { iv: amazonListing.Price?.Amount!};
offer.usedPrice = { iv: null };
}
else if (amazonListing.Condition?.Value === 'Used') {
offer.newPrice = { iv: null };
offer.usedPrice = { iv: amazonListing.Price?.Amount! }
}
console.log('Generated Offer:\n', offer);
let offersDto = await getAddNewOfferDto(offer);
});
await performSyncLocalizedSlugs(console.log);
});
}
})
.configureHelp();
program.parse();

View File

@ -0,0 +1,518 @@
import type { AmazonGetItem } from '../../data/models/multis/AmazonGetItem.ts';
import type { Brand } from '../../data/models/multis/Brand.ts';
import type { ContentsDto } from '../../data/internals/ContentsDtoT.ts';
import type { GetItemsResponse, Item } from 'amazon-pa-api5-node-ts';
import type { Listing } from '../../data/models/multis/Listing.ts';
import type { Multilingual } from '../../data/internals/MultilingualT.ts';
import type { NonMultilingual } from '../../data/internals/NonMultilingualT.ts';
import type { Product } from '../../data/models/multis/Product.ts';
import type { ProductCategory } from '../../data/models/multis/ProductCategory.ts';
import type { Seller } from '../../data/models/multis/Seller.ts';
import { arrayBuffer } from 'stream/consumers';
import { client, getContentsByIds, TIMEOUT_IN_SECONDS } from '../../data/core/client.ts';
import { createReadStream, type ReadStream } from 'fs';
import { getItems } from '../../apiclient/amazon.ts';
import { getBrandsUsingJsonQuery, getMarketplacesUsingJsonQuery, getProductCategoriesUsingJsonQuery, getProductsUsingJsonQuery, getSellersUsingJsonQuery } from '../../data/api-client.ts';
import { memfs } from 'memfs';
import { response } from '../../old-data/brands/first-aid-only-example-query-getitems.ts';
import { SCHEMAS } from '../../data/models/schemas.ts';
import axios from 'axios';
import ollama from 'ollama';
import slugify from 'slugify';
import type { Offer } from '../../data/models/multis/Offer.ts';
export function isValidASIN(asinOrNot: string) {
return asinOrNot.match(/[A-Z0-9]{10}/g) ? true : false;
}
export async function getAmazonGetItemsRequestSchemaDto() {
return await client.schemas.getSchema('amazon-pa-get-items-request');
}
export async function lookupAmazonASINs(asins: string[]) {
let amazonGetItemsRequestSchemaDto = await getAmazonGetItemsRequestSchemaDto();
let requestDate = new Date().toISOString();
//TODO: Implement API client
let apiResponse = await getItems(asins);
//TODO: disable when API client is ready: returns mock data for now
//particularly I want to log every Amazon lookup from now until forever in Squidex
// let apiResponse = response as GetItemsResponse;
let amazonGetItem: AmazonGetItem = {
apiResponse: { iv: apiResponse },
getItemsRequest: { iv: { schemaId: amazonGetItemsRequestSchemaDto.id, ItemIds: [ {
ItemId: (apiResponse.ItemsResult && apiResponse.ItemsResult.Items!.length > 0) ? apiResponse.ItemsResult!.Items![0].ASIN! : asins[0]
} ] } },
requestDate: { iv: requestDate }
}
let amazonGetItemDto = await client.contents.postContent(SCHEMAS.AMAZON_GET_ITEMS, {
publish: true,
body: amazonGetItem as any,
})
let amazonGetItemsDto = await getContentsByIds<AmazonGetItem>(SCHEMAS.AMAZON_GET_ITEMS, amazonGetItemDto.id);
return amazonGetItemsDto;
}
export async function getMarketplaceConnectionSchemaDto() {
return await client.schemas.getSchema('product-marketplace-connection');
}
export async function getAmazonMarketplaceConnectionSchemaDto() {
return await client.schemas.getSchema('product-marketplace-connection-amazon')
}
export async function getAmazonMarketplaceDto() {
return await getMarketplacesUsingJsonQuery(JSON.stringify({
filter: {
op: 'eq',
path: 'data.marketplaceName.en-US',
value: 'Amazon'
}
}));
}
export async function doesProductAlreadyExist(asin: string) {
return (await getProductsUsingJsonQuery(JSON.stringify({
filter: { not: { and: [{
op: 'empty',
path: 'data.marketplaceConnections.iv.connection.asin',
// value: 'Amazon'
}, {
op: 'eq',
path: 'data.marketplaceConnections.iv.marketplace',
eq: (await getAmazonMarketplaceDto()).items[0].id
}] } }
}))).items.length > 0;
}
export async function getBrandDtoByName(brandName: string) {
return (await getBrandsUsingJsonQuery(JSON.stringify({
filter: {
op: 'eq',
path: 'data.brandName.en-US',
value: brandName,
}
})));
}
export async function getAddNewBrandDtoByName(brandName: string) {
let brandDto = await client.contents.postContent(SCHEMAS.BRANDS, {
body: {
brandName: {
"en-US": brandName!,
"es-US": brandName!,
"fr-CA": brandName!
},
slug: {
"en-US": `en-US/${slugify(brandName!, { lower: true, trim: true })}`,
"es-US": `es-US/${slugify(brandName!, { lower: true, trim: true })}`,
"fr-CA": `fr-CA/${slugify(brandName!, { lower: true, trim: true })}`
},
}
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let brandsDto = await client.contents.getContents(SCHEMAS.BRANDS, { unpublished: true, ids: brandDto.id }, { timeoutInSeconds: TIMEOUT_IN_SECONDS }) as ContentsDto<Brand>;
return brandsDto;
}
export async function getSellerDtoByName(sellerName: string) {
return (await getSellersUsingJsonQuery(JSON.stringify({
filter: {
op: 'eq',
path: 'data.sellerName.en-US',
value: sellerName,
}
})));
}
export async function getAddNewSellerDtoByName(sellerName: string) {
let sellerDto = await client.contents.postContent(SCHEMAS.SELLERS, {
body: {
sellerName: {
"en-US": sellerName!,
"es-US": sellerName!,
"fr-CA": sellerName!
},
slug: {
"en-US": `en-US/${slugify(sellerName!, { lower: true, trim: true })}`,
"es-US": `es-US/${slugify(sellerName!, { lower: true, trim: true })}`,
"fr-CA": `fr-CA/${slugify(sellerName!, { lower: true, trim: true })}`
},
}
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let sellersDto = await client.contents.getContents(SCHEMAS.SELLERS, { unpublished: true, ids: sellerDto.id }, {timeoutInSeconds: TIMEOUT_IN_SECONDS}) as ContentsDto<Seller>;
return sellersDto;
}
export async function translateProductName_from_en_US_to_es_US(item: Item, brandName: string, productName_en_US: string) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`Translate just the product name "${productName_en_US}" into Latin American Spanish, formal, no commentary, no alternatives, ignoring the rest of this text.` +
`The brand name "${brandName}" remains unchanged. Here is the full product description for reference only:\n` +
`${item.ItemInfo?.Features?.DisplayValues?.map((item) => `- ${item}`).join('\n')}\n`,
}]
})).message.content;
}
export async function translateProductName_from_en_US_to_fr_CA(item: Item, brandName: string, productName_en_US: string) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`Translate just the product name "${productName_en_US}" into French Canadian, formal, no commentary, no alternatives, ignoring the rest of this text.` +
`The brand name "${brandName}" remains unchanged. Here is the full product description for reference only:\n` +
`${item.ItemInfo?.Features?.DisplayValues?.map((item) => `- ${item}`).join('\n')}\n`,
}]
})).message.content;
}
export async function generateAIProductDescription(productName_en_US: string, features: string[]) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`I have a niche wish style website called Dasher Supply. It contains many products which are great picks for delivery drivers. `+
`I need you to write me a product pitch based on the information the following rules: 1. please don't bother with introductions and conclusions, `+
`those are implied because these are articles on the website; 2. please, whatever you do, do not invent any new information about the products--you should `+
`only ever produce outputs based on the inputs; 3. if you aren't sure whether to call us Dashers or delivery drivers, do use delivery drivers or drivers, etc., `+
`as I do not want to cause any issues with the actual DoorDash company; 4. please no commentary, just provide the review based on what you know, but try`+
`to sell it to a delivery driver. Here is the product name:`+ '`' + productName_en_US + '`. Here are the product features as generally marketed on Amazon: '+
`${features.map((feature) => `- ${feature}`).join('\n')}.`
}]
})).message.content;
}
export async function translateProductDescription_from_en_US_to_es_US(brandName: string, productDescription_en_US: string) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`I have a product description in United States English that I need to translate to Latin American Spanish. If you aren't sure about something ` +
`please use your best judgement in selecting alternatives. The speech can be informal but we're mostly professionals selling products and we ` +
`want to include options for alternative languages. Please no commentary, just the translation. The brand is ${brandName}, don't translate it. Here is the description in English:\n` +
productDescription_en_US
}]
})).message.content;
}
export async function translateProductDescription_from_en_US_to_fr_CA(brandName: string, productDescription_en_US: string) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`I have a product description in United States English that I need to translate to French Canadian. If you aren't sure about something ` +
`please use your best judgement in selecting alternatives. The speech can be informal but we're mostly professionals selling products and we ` +
`want to include options for alternative languages. Please no commentary such as "Here is the translation to French Canadian:", just the text translation. `+
`I need to pipe the output of this command. The brand is ${brandName}, don't translate it. Here is the description in English:\n` +
productDescription_en_US
}]
})).message.content;
}
export async function getAllTopLevelProductCategories() {
return (await getProductCategoriesUsingJsonQuery()).items
.filter((category) =>
!!category.data?.parentCategory.iv || !category.data?.parentCategory.iv.length)
.map((category) => {
return { id: category.id, categoryName: category.data?.categoryName, description: category.data?.description, parentCategory: category.data?.parentCategory };
});
}
export async function getAllProductSubCategories(parentProductCategoryId: string) {
return (await getProductCategoriesUsingJsonQuery(JSON.stringify({ filter: { path: 'data.parentCategory.iv', op: 'eq', value: parentProductCategoryId } }))).items.map((category) => { return { id: category.id, categoryName: category.data?.categoryName, description: category.data?.description, parentCategory: category.data?.parentCategory||undefined }; });
}
export async function designAiTopLevelProductCategoryEvalQuestions(brandName: string, productName_en_US: string) {
const topLevelProductCategories = await getAllTopLevelProductCategories();
return [
// prompt 1
`My website is called Dasher Supply. It contains products and product categories. I need your help in categorizing a product.`,
// prompt 2
`I have ${topLevelProductCategories.length} top level product categories. The top level product categories I have already are (in JSON format):\n\n` +
JSON.stringify(topLevelProductCategories),
// prompt 3
`Which of these top level product categories should I pick to organize this product? ` +
`The brand name for the product is ${brandName}. The name of the product (in en-US) is ${productName_en_US}. ` +
`The product features (in US English) are:\n` +
`${response.ItemsResult?.Items![0].ItemInfo?.Features?.DisplayValues?.map((item) => `- ${item}`).join('\n')}\n`,
// prompt 4
`I need the answer in a certain format. In this case I must have only the category UUID as a properly escaped valid JavaScript string in double-quotes, no commentary.`,
];
}
export async function askAiTopLevelProductCategoryEvalQuestions(brandName: string, productName_en_US: string) {
const aiTopLevelCategoryEvalQuestions: string[] = await designAiTopLevelProductCategoryEvalQuestions(brandName, productName_en_US);
return JSON.parse((await ollama.chat({
model: 'llama3.1',
messages: aiTopLevelCategoryEvalQuestions.map(content => { return { role: 'user', content } }),
})).message.content).trim().toLowerCase() as string;
}
export async function designAiProductSubCategoryEvalQuestionsSet1(parentProductCategoryId: string, brandName: string, productName_en_US: string, features: string[]) {
const topLevelProductCategories = await getAllTopLevelProductCategories();
const productSubCategories = await getAllProductSubCategories(parentProductCategoryId);
const aiTopLevelCategoryEvalQuestions: string[] = await designAiTopLevelProductCategoryEvalQuestions(brandName, productName_en_US);
return [
// prompt 1
aiTopLevelCategoryEvalQuestions[0],
// prompt 2
aiTopLevelCategoryEvalQuestions[1],
// prompt 3
`The brand name for the product is ${brandName}. The name of the product (in en-US) is ${productName_en_US}. The product features (in US English) are:\n` +
`${features.map((item) => `- ${item}`).join('\n')}\n` +
`When asked previously about which top level product category, you picked ${parentProductCategoryId} as the closest match.`,
// prompt 4
`The second level product categories I have already are (in JSON format):\n\n` +
JSON.stringify(productSubCategories) +
` Ignoring the top level product categories, should I pick an existing second level category from the list (if any), or make a new one?`,
// prompt 5
`I need the answer in a certain format. In this case I must have only the answer as a valid JavaScript boolean to read in with JSON.parse. If you suggest making a new second level category, return true, no commentary. If you suggest using an existing second level category, return false, no commentary.`
];
}
export async function askAiProductSubCategoryEvalQuestionsSet1(parentProductCategoryId: string, brandName: string, productName_en_US: string, features: string[]) {
const aiSubCategoryEvalQuestion1 = await designAiProductSubCategoryEvalQuestionsSet1(parentProductCategoryId, brandName, productName_en_US, features);
return JSON.parse((await ollama.chat({
model: 'llama3.1',
messages: aiSubCategoryEvalQuestion1.map(content => { return { role: 'user', content } }),
})).message.content) as boolean;
}
export async function designAiProductSubCategoryEvalQuestionsSet2(response: GetItemsResponse, parentProductCategoryId: string, brandName: string, productName_en_US: string, features: string[], shouldCreateNewProductCategory: boolean) {
const topLevelProductCategories = await getAllTopLevelProductCategories();
const productSubCategories = await getAllProductSubCategories(parentProductCategoryId);
const aiTopLevelCategoryEvalQuestions: string[] = await designAiTopLevelProductCategoryEvalQuestions(brandName, productName_en_US);
return [
// prompt 1
aiTopLevelCategoryEvalQuestions[0],
// prompt 2
aiTopLevelCategoryEvalQuestions[1],
// prompt 3
`The brand name for the product is ${brandName}. The name of the product (in en-US) is ${productName_en_US}. The product features (in US English) are:\n` +
`${response.ItemsResult?.Items![0].ItemInfo?.Features?.DisplayValues?.map((item) => `- ${item}`).join('\n')}\n` +
`When asked previously about which top level product category, you picked ${parentProductCategoryId} as the closest match.`,
// prompt 4
`The second level product categories I have already are (in JSON format):\n\n` +
JSON.stringify(productSubCategories) +
` When asked whether I should pick an existing second level category from the list (if any) or make a new one you previously responded ${shouldCreateNewProductCategory?" that I should make a new one.":" that I should choose an existing one."}`,
// prompt 5
shouldCreateNewProductCategory
?
`What would be a good subcategory to use for this product based on what you know about the product, the existing categories, and the web site?`
:
`Which existing subcategory do you think I should use?`
,
// prompt 6
shouldCreateNewProductCategory
?
`In TypeScript, the interface looks like this:\n\n` +
`internals/NonMultilingualT.ts:` + "```\n" +
`export interface NonMultilingual<T> {\n` +
` [key: string]: T,\n` +
` iv: T,\n` +
`};\n` +
"```\n" +
`internals/MultilingualT.ts:` + "```" +
`export interface Multilingual<T> {\n` +
` [key: string]: T,\n` +
` 'en-US': T,\n` +
` 'es-US': T,\n` +
` 'fr-CA': T,\n` +
`};\n` +
"```\n" +
`models/multis/ProductCategory.ts:` + "```\n" +
`import type { Multilingual } from "../../internals/MultilingualT";\n` +
`import type { NonMultilingual } from "../../internals/NonMultilingualT";\n` +
`export interface ProductCategory {\n` +
` categoryName: Multilingual<string>, //multilingual name of category` +
` description: Multilingual<string>, //multilingual description of category` +
` parentCategory: NonMultilingual<string[]>, //parent category (exactly 1), though due to Squidex / MongoDB this is always an array of UUIDs with the UUID belonging to the UUID of the parent category or an empty array for top-level categories` +
`}` + "```\n\n" +
`I need the answer in a certain format. In this case I must have only the JSON for the new Category containing only the fields categoryName and description in all three languages, no commentary, no code comments, and with the UUID in the string array for parentCategory field, no commentary, no code comments, no id field, not in a markdown codeblock.`
:
`I need the answer in a certain format. In this case I must have only the category UUID as a properly escaped valid JavaScript string, no commentary.`,
];
}
export async function askAiProductSubCategoryEvalQuestionsSet2(response: GetItemsResponse, parentProductCategoryId: string, brandName: string, productName_en_US: string, features: string[], shouldCreateNewProductCategory: boolean): Promise<{
categoryName: Multilingual<string>,
description: Multilingual<string>,
parentCategory: NonMultilingual<string[]>,
}|string> {
const aiSubCategoryEvalQuestion2 = await designAiProductSubCategoryEvalQuestionsSet2(response, parentProductCategoryId, brandName, productName_en_US, features, shouldCreateNewProductCategory);
console.log(`>>>${aiSubCategoryEvalQuestion2}`);
const answer = (await ollama.chat({
model: 'llama3.1',
messages: aiSubCategoryEvalQuestion2.map(content => { return { role: 'user', content } }),
})).message.content;
console.log(`llama3.1>${answer}`);
const aiSubCategoryEvalQuestion2Answer = JSON.parse(answer) as {
categoryName: Multilingual<string>,
description: Multilingual<string>,
parentCategory: string[],
}|string;
if (typeof aiSubCategoryEvalQuestion2Answer === 'string') {
return aiSubCategoryEvalQuestion2Answer as string;
} else {
return {
categoryName: aiSubCategoryEvalQuestion2Answer.categoryName,
description: aiSubCategoryEvalQuestion2Answer.description,
parentCategory: { iv: aiSubCategoryEvalQuestion2Answer.parentCategory },
};
}
}
export async function generateAITagsForProduct(productName_en_US: string, features: string[]) {
const questions: string[] = [
`Provided the description for the product, please provide a valid JavaScript array of strings which represent tags on a website for the product, no commentary, no code comments, not in a markdown codeblock. The product name is ${productName_en_US}. The product features are:\n ${features.map((feature) => `- ${feature}`).join('\n')}`
];
let answer = (await ollama.chat({
model: 'llama3.1',
messages: questions.map(content => { return { role: 'user', content } }),
})).message.content;
return (JSON.parse(answer) as string[]).map(a => a.trim().toLowerCase());
}
export async function translateTags_from_en_US_to_es_US(tags_en_US: string[]) {
const questions: string[] = [
`Provided these website tags in United States English in the following JavaScript array of strings, please provide a valid JavaScript array of strings which represent the tags translated to Latin American Spanish, no commentary, no code comments, not in a markdown codeblock. The tags are:\n ${JSON.stringify(tags_en_US)}`
];
let answer = (await ollama.chat({
model: 'llama3.1',
messages: questions.map(content => { return { role: 'user', content } }),
})).message.content;
return (JSON.parse(answer) as string[]).map(a => a.trim().toLowerCase());
}
export async function translateTags_from_en_US_to_fr_CA(tags_en_US: string[]) {
const questions: string[] = [
`Provided these website tags in United States English in the following JavaScript array of strings, please provide a valid JavaScript array of strings which represent the tags translated to French Canadian, no commentary, no code comments, not in a markdown codeblock. The tags are:\n ${JSON.stringify(tags_en_US)}`
];
let answer = (await ollama.chat({
model: 'llama3.1',
messages: questions.map(content => { return { role: 'user', content } }),
})).message.content;
return (JSON.parse(answer) as string[]).map(a => a.trim().toLowerCase());
}
export async function getAddNewProductSubCategoryDto(parentProductCategoryId: NonMultilingual<string[]>, categoryName: Multilingual<string>, description: Multilingual<string>) {
let productCategoryDto = await client.contents.postContent(SCHEMAS.PRODUCT_CATEGORIES, {
publish: false,
body: {
categoryName,
description,
parentCategory: parentProductCategoryId,
},
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let productCategoriesDto = await client.contents.getContents(SCHEMAS.PRODUCT_CATEGORIES, { unpublished: true, ids: productCategoryDto.id }, {timeoutInSeconds: TIMEOUT_IN_SECONDS}) as ContentsDto<ProductCategory>;
return productCategoriesDto;
}
export async function translateAmazonDescription_from_en_US_to_es_US(brandName: string, features: string[]) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`Translate the Amazon description into Latin American Spanish, formal, no commentary, no alternatives. ` +
`The brand name ${brandName} remains unchanged. Here is the description:\n` +
`${features.map((feature) => `- ${feature}`).join('\n')}\n`,
}]
})).message.content;
}
export async function translateAmazonDescription_from_en_US_to_fr_CA(brandName: string, features: string[]) {
return (await ollama.chat({
model: 'llama3.1',
messages: [{
role: 'user',
content:
`Translate the Amazon description into French Canadian, formal, no commentary, no alternatives. ` +
`The brand name ${brandName} remains unchanged. Here is the description:\n` +
`${features.map((feature) => `- ${feature}`).join('\n')}\n`,
}]
})).message.content;
}
export async function getAddNewProductDtoByProduct(product: Product) {
let productDto = await client.contents.postContent(SCHEMAS.PRODUCTS, {
publish: false,
body: {
...product
},
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let productsDto = await client.contents.getContents(SCHEMAS.PRODUCTS, { unpublished: true, ids: productDto.id }, {timeoutInSeconds: TIMEOUT_IN_SECONDS}) as ContentsDto<Product>;
return productsDto;
}
export async function getAddNewProductListingDtoByProduct(listing: Listing) {
let listingDto = await client.contents.postContent(SCHEMAS.LISTINGS, {
publish: true,
body: {
...listing
}
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let listingsDto = await client.contents.getContents(SCHEMAS.LISTINGS, { unpublished: true, ids: listingDto.id }, {timeoutInSeconds: TIMEOUT_IN_SECONDS}) as ContentsDto<Listing>;
return listingsDto;
}
export function trimPeriods(str: string) {
return str.replace(/^\s+|\s+$/g, '').replace(/\.$/g, '');
}
export function removeQuotes(str: string) {
return str.replace(/['"]+/g, '');
}
export async function upsertAssetFolder(folderName: string, parentFolderId?: string|undefined) {
const assetFolders = await client.assets.getAssetFolders({ scope: 'Items', parentId: parentFolderId }, { timeoutInSeconds: TIMEOUT_IN_SECONDS });
let assetFolder;
let assetFolderLookup = assetFolders.items.filter(folder => folder.folderName === folderName);
if (assetFolderLookup.length === 0) {
assetFolder = await client.assets.postAssetFolder({ folderName: folderName, parentId: parentFolderId }, { timeoutInSeconds: TIMEOUT_IN_SECONDS });
}
else {
assetFolder = assetFolderLookup[0];
}
return assetFolder;
}
export async function getAllAssetsInFolder(assetFolderId: string) {
let assetsDto = await client.assets.getAssets({parentId: assetFolderId});
return assetsDto.items||[];
}
export async function uploadDownloadedImageToSquidexAsAsset(downloadUrl: string, assetFolderId: string) {
let filename = downloadUrl.substring(downloadUrl.lastIndexOf('/')+1);
let response = await axios.get(downloadUrl, { timeout: TIMEOUT_IN_SECONDS * 1000, responseType: 'arraybuffer' });
let assetDto = await client.assets.postAsset({ readable: response.data, fileName: filename }, { timeoutInSeconds: TIMEOUT_IN_SECONDS });
assetDto = await client.assets.putAsset(assetDto.id, { fileName: filename, metadata: { ...assetDto.metadata, 'amazon-url': downloadUrl }, tags: ['amazon', 'product'] })
assetDto = await client.assets.putAssetParent(assetDto.id, { parentId: assetFolderId });
return assetDto;
}
export async function getAddNewOfferDto(offer: Offer) {
let offerDto = await client.contents.postContent(SCHEMAS.OFFERS, {
publish: true,
body: offer as any,
}, {
timeoutInSeconds: TIMEOUT_IN_SECONDS,
});
let offersDto = await client.contents.getContents(SCHEMAS.OFFERS, { unpublished: true, ids: offerDto.id }, {timeoutInSeconds: TIMEOUT_IN_SECONDS}) as ContentsDto<ProductCategory>;
return offersDto;
}

View File

@ -0,0 +1,169 @@
import Fastify, { type FastifyRequest } from 'fastify';
import fastifyMiddie from '@fastify/middie';
// import fastifyStatic from '@fastify/static';
// import { fileURLToPath } from 'node:url';
// import { handler as ssrHandler } from './dist/server/entry.mjs';
import { config } from '../config.ts';
// import { series } from 'node:async';
// import { spawn } from 'node:child_process';
import type { ChildProcess } from 'child_process';
import { performSyncLocalizedSlugs } from '../data/api-client.ts';
import util from 'node:util';
// import { parse as parseQueryString } from 'node:querystring';
// const app = Fastify({ logger: true });
// await app
// .register(fastifyStatic, {
// root: fileURLToPath(new URL('./dist/client', import.meta.url)),
// })
// .register(fastifyMiddie);
// app.use(ssrHandler);
// app.listen({ host: '0.0.0.0', port: config.port }, (err, address) => {
// if (err) {
// app.log.error(err);
// process.exit(1);
// }
// app.log.info(`Fastify web server with Astro middleware listening at ${address}.`)
// });
const webhook = Fastify({ logger: true });
await webhook
.register(fastifyMiddie);
webhook.get("/", async (req, reply) => {
let buffer = "200 [OK] Webhook server";
reply.send({
status: 'ok',
text: buffer
});
return reply;
});
webhook.post("/cache-localized-slugs", async (req, reply) => {
let buffer = '';
const trapLog = (...args: any[]) => {
const line = args.map((val) => typeof val === 'string' ? val : util.inspect(val)).join(' ');
buffer += line + '\n';
webhook.log.info(line);
};
performSyncLocalizedSlugs(trapLog).then(() => {
reply.send({
status: 'ok',
text: buffer
});
});
return reply;
})
webhook.get("/cache-localized-slugs", async (req, reply) => {
let buffer = '';
const trapLog = (...args: any[]) => {
const line = args.map((val) => typeof val === 'string' ? val : util.inspect(val)).join(' ');
buffer += line + '\n';
webhook.log.info(line);
};
performSyncLocalizedSlugs(trapLog).then(() => {
reply.send({
status: 'ok',
text: buffer
});
});
return reply;
})
// webhook.get("/get-site-data", async (req, reply) => {
// let site = await getSite();
// reply.send(site);
// return reply;
// });
// webhook.get("/get-homepage-data", async (req, reply) => {
// let homePage = await getSiteHomePage(SupportedLanguages['en-US'])
// reply.send(homePage);
// return reply;
// });
// type GetPageByLangSlugRequest = FastifyRequest<{Querystring:{lang: string,slug: string}}>;
// webhook.get("/get-page-by-lang-slug", async (req: GetPageByLangSlugRequest, reply) => {
// let lang = req.query.lang;
// let slug = req.query.slug;
// let page = await getPageByLangSlug(lang, slug);
// reply.send(page);
// return reply;
// });
// let rebuildLock: ChildProcess[] = [];
// webhook.post("/rebuild", async (req, reply) => {
// const strapiEventName = req.headers['X-Strapi-Event'];
// console.log('Strapi Event', strapiEventName);
// console.log('Request Body', req.body);
// return "Hello, World!";
// // let startTime = Date.now();
// // if (rebuildLock.length < 1) {
// // let instance;
// // Promise.all([ instance = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['run-script', 'astro', 'build'], {
// // stdio: 'overlapped',
// // })]);;
// // rebuildLock.push(instance);
// // let stdout = '';
// // let stderr = '';
// // instance.stdout.on('data', (data) => {
// // stdout += data.toString();
// // })
// // instance.stderr.on('data', (data) => {
// // stderr += data.toString();
// // })
// // instance.on('error', (err) => {
// // let endTime = Date.now();
// // let duration = endTime - startTime;
// // rebuildLock.splice(0, 1);
// // if (err) {
// // reply.statusCode = 500;
// // }
// // reply.send({
// // status: err ? 'err' : 'ok',
// // err: err || undefined,
// // stdout,
// // stderr,
// // startTime,
// // endTime,
// // duration,
// // });
// // });
// // instance.on('close', (exitCode) => {
// // let endTime = Date.now();
// // let duration = endTime - startTime;
// // rebuildLock.splice(0, 1);
// // let err = (exitCode != 0);
// // if (err) {
// // reply.statusCode = 500;
// // }
// // reply.send({
// // status: err ? 'err' : 'ok',
// // err: err || undefined,
// // exitCode,
// // stdout,
// // stderr,
// // startTime,
// // endTime,
// // duration,
// // });
// // });
// // }
// // else {
// // reply.statusCode = 503; // inform client of HTTP 503 Service Unavailable
// // reply.header('Retry-After', '10'); // inform client to retry request after 10 seconds
// // reply.send({
// // status: 'info',
// // err: 'A rebuild is currently in progress and another rebuild is currently queued. Please wait for the rebuild to finish.',
// // })
// // }
// // return reply;
// });
webhook.listen({ host: '0.0.0.0', port: config.webhookPort }, (err, address) => {
if (err) {
webhook.log.error(err);
process.exit(1);
}
webhook.log.info(`Fastify web server with webhook middleware listening at ${address}.`)
// webhook.log.info(`You can trigger a site rebuild at ${address}/rebuild.`)
const trapLog = (...args: any[]) => {
const line = args.map((val) => typeof val === 'string' ? val : util.inspect(val)).join(' ');
webhook.log.info(line);
};
performSyncLocalizedSlugs(trapLog);
});

0
src/apps/ssr-server.ts Normal file
View File

View File

@ -0,0 +1,30 @@
---
interface Banner {
editToken?: string,
homePageLink: string,
siteName: string,
}
const { editToken, homePageLink, siteName } = Astro.props;
---
<h1 class="center" data-squidex-token={editToken||''}><a href={homePageLink}><span class="text-gradient">{siteName}</span></a></h1>
<style>
h1 {
font-size: 4rem;
font-weight: 700;
line-height: 1;
margin-top: .3em;
margin-bottom: .3em;
font-family: "Holtwood One SC", sans-serif;
font-weight: 600;
text-shadow: -5px -5px 3px rgba(var(--accent-light), 7%), 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%;
}
</style>

View File

@ -0,0 +1,84 @@
---
import type { Brand } from "../data/models/multis/Brand";
import { getAssetById, getLocaleField } from "../data/api-client";
import path from "node:path";
import { renderMarkdown } from "../lib/rendering";
interface Props {
brand: Brand,
editToken?: string,
locale: string,
}
const { brand, editToken, locale } = Astro.props;
let brandLogoImage = path.posix.join('/img', (await getAssetById(brand.logoImage[locale]))
.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
---
<div class="brand" data-squidex-token={editToken||''}>
<div class="flex">
{ brandLogoImage &&
<img src={brandLogoImage} alt={brand.brandName[locale]} title={brand.brandName[locale]} />
}
<div class="flex-right">
{getLocaleField(locale, brand.shortDescription) && <div class="short-desc"><Fragment set:html={renderMarkdown(getLocaleField(locale, brand.shortDescription)!)} /></div> }
</div>
</div>
{getLocaleField(locale, brand.longDescription) && <div class="after-flex"><Fragment set:html={renderMarkdown(getLocaleField(locale, brand.longDescription)!)} /></div> }
</div>
<style>
.brand {
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: 0.5rem;
border-radius: 8px;
font-weight: 400;
font-style: normal;
text-align: left;
}
.brand img {
min-width: 8rem;
max-width: 10rem;
}
.brand .flex {
display: flex;
gap: 0.8rem;
align-items: center;
}
.brand .after-flex p {
text-indent: 5em each-line;
color: pink;
}
.brand .short-desc {
font-family: "Caveat", cursive;
font-size: 2.2rem;
}
.brand .float-left {
justify-content: flex-start;
}
.brand .flex-right {
justify-content: flex-start;
position: relative;
}
.brand 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;
}
.brand strong {
color: rgb(var(--accent-light));
/* font-weight: 800; */
}
</style>

View File

@ -1,16 +1,33 @@
--- ---
import type { Brand } from '../data/brands/brand'; import type { Brand } from "../data/models/multis/Brand";
import { getAssetById } from "../data/api-client";
import path from "node:path";
interface Props { interface Props {
brand: Brand brand: Brand,
editToken?: string,
locale: string,
} }
const { brand } = Astro.props; const { brand, editToken, locale } = Astro.props;
let brandLogoImage = path.posix.join('/img', (await getAssetById(brand.logoImage[locale]))
.links['content']
.href
//The purpose of .split('/').reverse().filter(...2...).reverse().join('/') is to
//extract the last two directories from the end of the path, which we will
//use to form the path to the ../pages/img/[...imageLookup].astro handler, e.g.,
//in the form of `/img/${uuid}/${fileName}.${ext}`.
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
--- ---
<li class="brand-card"> <li class="brand-card" data-squidex-token={editToken||''}>
<a href={`/brand/${brand.slug}`}> <a href={`/${brand.slug[locale]}/`}>
{brand.logoUrl !== undefined && <img src={brand.logoUrl} alt={brand.name} title={brand.name} />} <img src={brandLogoImage} alt={brand.brandName[locale]} title={brand.brandName[locale]} />
</a> </a>
</li> </li>
<style> <style>

View File

@ -0,0 +1,90 @@
---
export interface Breadcrumb {
text: string;
url: string;
gradient?: boolean;
editToken?: string;
}
interface Props {
editToken?: string;
breadcrumbs: Breadcrumb[];
}
const { breadcrumbs, editToken } = Astro.props;
---
{breadcrumbs &&
breadcrumbs.length &&
<nav class="breadcrumbs" aria-label="breadcrumb" data-squidex-token={editToken||''}>
<ol class="breadcrumb">
{breadcrumbs.map((breadcrumb: Breadcrumb, index: number, breadcrumbs: Breadcrumb[]) => {
let isLastCrumb = ((index + 1) === breadcrumbs.length);
if (isLastCrumb) {
//the last crumb doesn't get a link
return (
<li class="breadcrumb-item active" aria-current="page" data-squidex-token={breadcrumb.editToken||''}>{breadcrumb.text}</li>
);
}
else {
return (
<li class="breadcrumb-item" data-squidex-token={breadcrumb.editToken||''}>
<a href={breadcrumb.url}>{(breadcrumb.gradient &&
<span class="text-gradient">{breadcrumb.text}</span>)
||
breadcrumb.text}</a>
</li>
);
}
})}
</ol>
</nav>
<style>
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
text-shadow: -5px -5px 3px rgba(var(--accent-light), 7%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%);
}
.breadcrumbs {
background-color: #23262d;
border: 1px solid rgba(var(--accent-light), 25%);
border-radius: 7px;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
margin-bottom: 0.5rem;
place-content: center;
}
.breadcrumb {
margin: 0 0 0 0;
padding: 0 0 0 0;
place-content: center;
}
.breadcrumbs, .breadcrumb, .breadcrumb-item, .breadcrumb-item a, .breadcrumb-item a:link, .breadcrumb-item a:visited {
color: #eee;
font-family: "Holtwood One SC", sans-serif;
}
.breadcrumb-item a:hover, .breadcrumb-item a:active {
text-decoration: underline;
color: #fff;
text-shadow: -5px -5px 3px rgba(var(--accent-light), 7%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%);
}
.breadcrumb-item.active {
color: #fff;
text-shadow: -5px -5px 3px rgba(var(--accent-light), 7%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%);
}
.breadcrumb-item {
list-style-type: none;
display: inline;
margin: 0;
+ .breadcrumb-item::before {
display: inline-block;
padding-right: 1rem;
padding-left: 0.5rem;
color: #999;
content: ">";
text-shadow: -5px -5px 3px rgba(var(--accent-light), 7%), 3px -3px 0 rgba(var(--accent-light), 10%), -2px 2px 0 rgba(var(--accent-light), 5%), 5px 5px 0 rgba(var(--accent-light), 10%);
}
}
</style>
}

View File

@ -0,0 +1,42 @@
---
interface Callout {
editToken?: string,
text: string,
}
const { editToken, text } = Astro.props;
---
<div class="callout" data-squidex-token={editToken||''} set:html={text}></div>
<style>
.callout {
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: 0.5rem;
border-radius: 8px;
text-align: center;
font-family: "Caveat", cursive;
font-weight: 400;
font-style: normal;
font-size: 2.2rem;
}
.callout p {
display: inline;
margin-bottom: 0rem !important;
}
.callout 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;
}
.callout strong {
color: rgb(var(--accent-light));
}
a, a:link, a:visited { text-decoration: none; color: #fff }
a:hover { text-decoration: underline; color: #fff }
</style>

View File

@ -1,65 +0,0 @@
---
import type { Category } from '../data/categories';
interface Props {
category: Category
}
const { category } = Astro.props;
---
<li class="category-card">
<a href={`/category/${category.slug}`}>
{category.imageUrl !== undefined && <img src={category.imageUrl} alt={category.category} />}
<h2>
{category.category}
<span>&rarr;</span>
</h2>
<p>
{category.description}
</p>
</a>
</li>
<style>
.category-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);
}
.category-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;
}
.category-card img {
max-width: 100%;
}
.category-card h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.category-card p {
margin-top: 0.5rem;
margin-bottom: 0;
}
.category-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.category-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent-light));
}
</style>

View File

@ -0,0 +1,340 @@
---
// import type { Brand, Page, PageBrand, PageCallout, PageContent, Site, SquidexComponent, QueryComponent } from '../data/squidex-client';
import type { Brand } from "../data/models/multis/Brand";
import type { Component } from "../data/internals/Component";
import type { Marketplace } from "../data/models/multis/Marketplace";
import type { Page } from "../data/models/multis/Page";
import type { PageBrand } from "../data/models/components/PageBrand";
import type { PageProduct } from "../data/models/components/PageProduct";
import type { PageCallout } from "../data/models/components/PageCallout";
import type { PageContent } from "../data/models/components/PageContent";
import type { PageProductCategory } from "../data/models/components/PageProductCategory";
import type { Product } from "../data/models/multis/Product";
import type { ProductCategory } from "../data/models/multis/ProductCategory";
import type { Seller } from "../data/models/multis/Seller";
import type { Site } from "../data/models/singles/Site";
import type { QueryComponent } from "../data/models/components/QueryComponent";
import { SupportedLocales } from "../data/internals/MultilingualT";
import { getBrandsByIds, getBrandsUsingJsonQuery, getMarketplacesByIds, getMarketplacesUsingJsonQuery, getPagesByIds, getProductCategoriesByIds, getProductCategoriesUsingJsonQuery, getProductsByIds, getProductsUsingJsonQuery, getSellersByIds, getSellersUsingJsonQuery } from '../data/api-client';
import BrandComponent from './Brand.astro';
import ProductComponent from './Product.astro';
import Breadcrumbs, { type Breadcrumb } from './Breadcrumbs.astro';
import BrandCard from './BrandCard.astro';
import PageContentComponent from './Content.astro';
import Callout from './Callout.astro';
import ProductCard from './ProductCard.astro';
import ProductCategoryComponent from './ProductCategory.astro';
import ProductCategoryCard from './ProductCategoryCard.astro';
import SellerCard from "./SellerCard.astro";
import SellerComponent from './Seller.astro';
import MarketplaceComponent from './Marketplace.astro';
import MarketplaceCard from './MarketplaceCard.astro';
import { renderMarkdown } from '../lib/rendering';
import type { ContentsDto } from "../data/internals/ContentsDtoT";
import type { PageMarketplace } from "../data/models/components/PageMarketplace";
import type { PageSeller } from "../data/models/components/PageSeller";
import type { ContentDto } from "../data/internals/ContentDtoT";
interface Props {
componentRouter: Component[],
homePage: Page,
isHomePage: boolean,
locale: string,
page: Page,
pageEditToken?: string,
site: Site,
siteEditToken?: string,
brand?: Brand,
marketplace?: Marketplace,
productDto?: ContentsDto<Product>,
productCategory?: ProductCategory,
seller?: Seller,
}
const { brand, componentRouter, homePage, isHomePage, locale, marketplace, page, pageEditToken, productDto, productCategory, seller, site, siteEditToken } = Astro.props;
const renderContext = { ...Astro.props };
async function flatWalkSubCategories (productCategoryId: string): Promise<ContentDto<ProductCategory>[]> {
let mySubCategories = await getProductCategoriesUsingJsonQuery(JSON.stringify({ filter: { op: 'eq', path: 'data.parentCategory.iv', value: productCategoryId}}));
let walked = [...mySubCategories.items];
for (let sc = 0; sc < mySubCategories.items.length; sc++) {
let deepWalk = await flatWalkSubCategories(mySubCategories.items[sc].id);
walked.push(...deepWalk);
}
return walked;
}
---
{componentRouter.map(async (dynComponent) => {
switch (dynComponent.schemaName) {
case 'page-breadcrumbs':
const siteCrumb = ({ homePage, site, siteEditToken }: { homePage: Page, site: Site, siteEditToken?: string }): Breadcrumb => {
return {
text: site.siteName[locale],
url: `/${homePage.slug[locale]}/`,
gradient: true,
editToken: siteEditToken,
};
};
const pageCrumb = ({ page, pageEditToken }: { page: Page, pageEditToken?: string }): Breadcrumb => {
return {
text: page.title[locale],
url: `/${page.slug[locale]}`,
gradient: false,
editToken: pageEditToken,
}
};
const categoryCrumb = ({ category, categoryEditToken }: { category: ProductCategory, categoryEditToken?: string }): Breadcrumb => {
return {
text: category.categoryName[locale],
url: `/${category.slug[locale]}`,
gradient: false,
editToken: categoryEditToken,
}
};
const walkParentPagesForCrumbs = async ({homePage, site, startPage, startPageEditToken}: {homePage: Page, site: Site, startPage: Page, startPageEditToken?: string}) => {
const MAX_RECURSION_DEPTH = 10;
const walkParentPagesForCrumbsRecursive = async (recursePage: Page, abortAfterRecursions: number, recursePageEditToken?: string) => {
let parentPages: Breadcrumb[] = [];
if (recursePage.parentPage && recursePage.parentPage.iv && recursePage.parentPage.iv.length > 0) {
if (abortAfterRecursions !== 0) {
let parentPagesDto = await getPagesByIds(recursePage.parentPage.iv[0]);
parentPages = await walkParentPagesForCrumbsRecursive(parentPagesDto.items[0].data!, abortAfterRecursions--);
}
}
if (recursePage.slug[locale] !== homePage.slug[locale]) {
parentPages.push(pageCrumb({ page: recursePage, pageEditToken: recursePageEditToken }));
}
return parentPages;
};
let breadcrumbs = (await walkParentPagesForCrumbsRecursive(startPage, MAX_RECURSION_DEPTH, startPageEditToken));
return breadcrumbs;
};
const walkParentCategoriesForCrumbs = async ({startCategory, startCategoryEditToken}: {startCategory: ProductCategory, startCategoryEditToken: string}) => {
const MAX_RECURSION_DEPTH = 10;
const walkParentCategoriesForCrumbsRecursive = async (recurseCategory: ProductCategory, abortAfterRecursions: number, recurseCategoryEditToken?: string) => {
let parentCategories: Breadcrumb[] = [];
if (recurseCategory.parentCategory && recurseCategory.parentCategory.iv && recurseCategory.parentCategory.iv.length > 0) {
if (abortAfterRecursions !== 0) {
let parentCategoriesDto = await getProductCategoriesByIds(recurseCategory.parentCategory.iv[0]);
parentCategories = await walkParentCategoriesForCrumbsRecursive(parentCategoriesDto.items[0].data!, abortAfterRecursions--, parentCategoriesDto.items[0].editToken);
}
}
if (startCategory.slug !== recurseCategory.slug) {
parentCategories.push(categoryCrumb({ category: recurseCategory, categoryEditToken: recurseCategoryEditToken }));
}
return parentCategories;
};
let breadcrumbs = (await walkParentCategoriesForCrumbsRecursive(startCategory, MAX_RECURSION_DEPTH));
return breadcrumbs;
};
let breadcrumbs: Breadcrumb[] = [
siteCrumb({ homePage, site, siteEditToken }),
...productCategory && productCategory.parentCategory && productCategory.parentCategory.iv && productCategory.parentCategory.iv[0] ? await walkParentCategoriesForCrumbs({ startCategory: productCategory, startCategoryEditToken: pageEditToken! }) : [],
...await walkParentPagesForCrumbs({ homePage, site, startPage: page, startPageEditToken: pageEditToken }),
...isHomePage?[pageCrumb({ page, pageEditToken })]:[],
]
return (
<Breadcrumbs breadcrumbs={breadcrumbs} editToken={pageEditToken} />
)
case 'page-content':
let content = dynComponent as PageContent;
return (
<PageContentComponent editToken={pageEditToken} text={renderMarkdown(content.content, renderContext)} />
);
case 'page-callout':
let callout = dynComponent as PageCallout;
return (
<Callout editToken={pageEditToken} text={renderMarkdown(callout.text, renderContext)} />
);
case 'page-brands-query':
let brandsQuery = dynComponent as QueryComponent;
let brandsDto = (await getBrandsUsingJsonQuery(JSON.stringify(brandsQuery.jsonQuery)));
let brands = brandsDto!.items;
let brandsHeading: { [key: string]: string } = {
'en-US': "Brands",
'es-US': "Marcas",
'fr-CA': "Marques",
};
return (
brands.length > 0 && <h3 class="section">{brandsHeading[locale]}</h3>
<ul class="link-card-grid brands" data-squidex-token={pageEditToken}>
{brands.sort((a, b) => a.data!.brandName[locale].localeCompare(b.data!.brandName[locale])).map(brandDto => {
return (
<BrandCard
locale={locale}
editToken={brandDto.editToken}
brand={brandDto.data!}
/>
);
})}
</ul>
);
case 'page-brand':
let brandComponent = dynComponent as PageBrand;
let brandId = brandComponent.brand ? brandComponent.brand[0] : '';
let brandForComponent = brand || (await getBrandsByIds(brandId)).items[0].data!;
return (
<BrandComponent locale={locale} brand={brandForComponent} editToken={pageEditToken} />
);
case 'page-marketplace':
let marketplaceComponent = dynComponent as PageMarketplace;
let marketplaceId = marketplaceComponent.marketplace ? marketplaceComponent.marketplace[0] : '';
let marketplaceForComponent = marketplace || (await getMarketplacesByIds(marketplaceId)).items[0].data!;
return (
<MarketplaceComponent locale={locale} marketplace={marketplaceForComponent} editToken={pageEditToken} />
);
case 'page-seller':
let sellerComponent = dynComponent as PageSeller;
let sellerId = sellerComponent.seller ? sellerComponent.seller[0] : '';
let sellerForComponent = seller || (await getSellersByIds(sellerId)).items[0].data!;
return (
<SellerComponent locale={locale} seller={sellerForComponent} editToken={pageEditToken} />
);
case 'page-marketplaces-query':
let marketplacesQuery = dynComponent as QueryComponent;
let marketplacesDto = (await getMarketplacesUsingJsonQuery(JSON.stringify(marketplacesQuery.jsonQuery)))!;
let marketplaces = marketplacesDto.items;
let marketplacesHeading: { [key: string]: string } = {
'en-US': "Marketplaces",
'es-US': "Mercados",
'fr-CA': "Marchés",
};
return (
marketplaces.length > 0 && <h3 class="section">{marketplacesHeading[locale]}</h3>
<ul class="link-card-grid marketplaces" data-squidex-token={pageEditToken}>
{marketplaces.sort((a, b) => a.data!.marketplaceName[locale].localeCompare(b.data!.marketplaceName[locale])).map(marketplaceDto => (
<MarketplaceCard
editToken={marketplaceDto.editToken}
locale={locale}
marketplace={marketplaceDto.data!}
/>
))}
</ul>
);
case 'page-sellers-query':
let sellersQuery = dynComponent as QueryComponent;
let sellersDto = (await getSellersUsingJsonQuery(JSON.stringify(sellersQuery.jsonQuery)))!;
let sellers = sellersDto.items;
let sellersHeading: { [key: string]: string } = {
'en-US': "Sellers",
'es-US': "Vendedores",
'fr-CA': "Vendeurs",
};
return (
sellers.length > 0 && <h3 class="section">{sellersHeading[locale]}</h3>
<ul class="link-card-grid sellers" data-squidex-token={pageEditToken}>
{sellers.sort((a, b) => a.data!.sellerName[locale].localeCompare(b.data!.sellerName[locale])).map(sellerDto => (
<SellerCard
editToken={sellerDto.editToken}
locale={locale}
seller={sellerDto.data!}
/>
))}
</ul>
);
case 'page-product-categories-query':
let productCategoriesQuery = dynComponent as QueryComponent;
let productCategoriesDto = (await getProductCategoriesUsingJsonQuery(JSON.stringify(productCategoriesQuery.jsonQuery)))!;
let productCategories = [];
for (let pc = 0; pc < productCategoriesDto.items.length; pc++) {
let productCategoryDto = productCategoriesDto.items[pc];
let subCategories = await flatWalkSubCategories(productCategoryDto.id);
let subQueryDto = await getProductsUsingJsonQuery(JSON.stringify({ filter: { op: 'in', path: 'data.categories.iv', value: [productCategoryDto.id, ...subCategories.map((sc) => sc.id)] }}));
let hasProducts = subQueryDto.items.length > 0;
if (hasProducts) {
productCategories.push(productCategoryDto);
}
}
let productCategoriesHeading: { [key: string]: string } = {
'en-US': "Product Categories",
'es-US': "Categorías de productos",
'fr-CA': "Catégories de produits",
};
return (
(productCategories.length > 0) && <h3 class="section">{productCategoriesHeading[locale]}</h3>
<ul class="link-card-grid" data-squidex-token={pageEditToken}>
{productCategories.sort((a, b) => a.data!.categoryName[locale].localeCompare(b.data!.categoryName[locale])).map(productCategoryDto => {
let editToken = productCategoryDto.editToken;
return (
<ProductCategoryCard
editToken={editToken||''}
locale={locale}
productCategory={productCategoryDto.data!}
/>
);
})}
</ul>
);
case 'page-products-query':
let productsQuery = dynComponent as QueryComponent;
// console.log(JSON.stringify(productsQuery.jsonQuery));
let productsDto = (await getProductsUsingJsonQuery(JSON.stringify(productsQuery.jsonQuery)))!;
let products = productsDto.items;
let productsHeading: { [key: string]: string } = {
'en-US': "Products",
'es-US': "Productos",
'fr-CA': "Produits",
};
return (
products.length > 0 && <h3 class="section">{productsHeading[locale]}</h3>
<ul class="link-card-grid" data-squidex-token={pageEditToken}>
{products.sort((a, b) => a.data!.productName[locale].localeCompare(b.data!.productName[locale])).map(productDto => {
let editToken = productDto.editToken;
return (
<ProductCard
editToken={editToken||''}
locale={locale}
productDto={productDto}
/>
);
})}
</ul>
);
case 'page-product-category':
let productCategoryComponent = dynComponent as PageProductCategory;
let productCategoryId = productCategoryComponent.productCategory ? productCategoryComponent.productCategory[0] : '';
let productCategoryForComponent = productCategory || (await getProductCategoriesByIds(productCategoryId)).items[0].data!;
return (
<ProductCategoryComponent locale={locale} productCategory={productCategoryForComponent} editToken={pageEditToken} />
)
case 'page-product':
let productComponent = dynComponent as PageProduct;
// let productId = productComponent.product ? productComponent.product[0] : '';
// let productDto = productDto || (await getProductsByIds(productId)).items[0];
return (
<ProductComponent locale={locale} productDto={productDto!} editToken={pageEditToken} />
)
default:
return (
<div class="center" data-squidex-token={pageEditToken}><p>Unsupported or unknown dynamic component {dynComponent.schemaName}.</p></div>
);
}
})}
<style slot="head">
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 3fr));
gap: 1em;
padding: 0;
}
.link-card-grid.brands, .link-card-grid.marketplaces {
display: flex;
gap: 0.5em;
padding: 0;
place-items: center;
place-content: center;
}
h3.section {
font-family: Urbanist, sans-serif;
font-style: italic;
font-weight: 200;
font-size: 1em;
color: rgb(255, 255, 255, 0.5);
text-shadow: -5px -5px 3px rgba(90, 78, 95, 0.25), 3px -3px 0 rgba(90, 78, 100, 0.25), -2px 2px 0 rgba(90, 78, 151, 0.25), 5px 5px 0 rgba(90, 78, 125, .2);
width: 100%;
border-bottom: solid 1px rgba(90, 78, 95, 0.8);
}
</style>

View File

@ -0,0 +1,25 @@
---
interface Content {
editToken: string,
text: string,
}
const { editToken, text } = Astro.props;
---
<div class="content" data-squidex-token={editToken} set:html={text}></div>
<style>
.content {
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: 0.5rem;
border-radius: 8px;
font-family: "Urbanist", sans-serif;
font-weight: 400;
font-style: normal;
}
a, a:link, a:visited { text-decoration: none; color: #fff }
a:hover { text-decoration: underline; color: #fff }
</style>

View File

@ -0,0 +1,16 @@
---
import type { Marketplace } from "../data/models/multis/Marketplace";
import { getMarketplacesUsingJsonQuery } from "../data/api-client";
import { renderMarkdown } from "../lib/rendering";
interface Props {
locale: string,
}
const { locale } = Astro.props;
const allMarketplacesDto = await getMarketplacesUsingJsonQuery();
---
{allMarketplacesDto.items.map((marketplaceDto) => (
<Fragment data-squidex-token={marketplaceDto.editToken} set:html={renderMarkdown(marketplaceDto.data?.disclaimer[locale]||'')}></Fragment>
))}

View File

@ -0,0 +1,115 @@
---
import type { Marketplace } from "../data/models/multis/Marketplace";
import { getAssetById, getLocaleField } from "../data/api-client";
import path from "node:path";
import { renderMarkdown } from "../lib/rendering";
interface Props {
marketplace: Marketplace,
editToken?: string,
locale: string,
}
const { marketplace, editToken, locale } = Astro.props;
let marketplaceLogoImageAsset = await getAssetById(marketplace.logoImage[locale][0]);
let marketplaceLogoImage = path.posix.join('/img',
marketplaceLogoImageAsset.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
---
<div class="marketplace" data-squidex-token={editToken||''}>
<div class="flex">
{ marketplaceLogoImage &&
marketplaceLogoImageAsset.metadata['background-color']
?
<img
src={marketplaceLogoImage}
alt={marketplace.marketplaceName[locale]}
title={marketplace.marketplaceName[locale]}
style={`background-color: ${marketplaceLogoImageAsset.metadata['background-color']}`}
/>
:
<img
src={marketplaceLogoImage}
alt={marketplace.marketplaceName[locale]}
title={marketplace.marketplaceName[locale]}
/>
}
<div class="flex-right">
{marketplace.shortDescription && marketplace.shortDescription[locale] && <div class="short-desc"><Fragment set:html={renderMarkdown(marketplace.shortDescription[locale]||'')} /></div> }
</div>
</div>
</div>
<div class="marketplace-dark" data-squidex-token={editToken||''}>
<div class="flex">
{marketplace.longDescription && marketplace.longDescription[locale] && <div class="after-flex"><Fragment set:html={renderMarkdown(marketplace.longDescription[locale]||'')} /></div> }
</div>
</div>
<style>
.marketplace, .marketplace-dark {
margin-bottom: 2rem;
border: 1px solid rgba(var(--accent-light), 25%);
padding: 0.5rem;
border-radius: 8px;
font-weight: 300;
font-style: normal;
text-align: left;
}
.marketplace {
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
}
.marketplace-dark {
padding: 1rem;
background-color: rgb(35, 38, 45);
}
.marketplace img {
min-width: 8rem;
max-width: 10rem;
border-radius: 7px;
}
.marketplace .flex {
display: flex;
gap: 0.8rem;
align-items: center;
}
.marketplace .after-flex p {
text-indent: 5em each-line;
color: pink;
}
.marketplace .short-desc {
font-family: "Caveat", cursive;
font-size: 2.2rem;
}
.marketplace .float-left {
justify-content: flex-start;
}
.marketplace .flex-right {
justify-content: flex-start;
position: relative;
}
.marketplace 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;
}
.marketplace strong {
color: rgb(var(--accent-light));
/* font-weight: 800; */
}
</style>
<style is:global>
.marketplace a, .marketplace a:link, .marketplace a:visited, .marketplace-dark a, .marketplace-dark a:link, .marketplace-dark a:visited {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,84 @@
---
import path from "node:path";
import type { Marketplace } from "../data/models/multis/Marketplace";
import { getAssetById } from "../data/api-client";
interface Props {
editToken?: string,
marketplace: Marketplace,
locale: string,
}
const { editToken, marketplace, locale } = Astro.props;
let marketplaceLogoImageAsset = await getAssetById(marketplace.logoImage[locale][0]);
let marketplaceLogoImage = path.posix.join('/img',
marketplaceLogoImageAsset.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
---
<li class="marketplace-card" data-squidex-token={editToken}>
<a href={`/${marketplace.slug[locale]}/`}>
{ marketplaceLogoImageAsset.metadata['background-color']
?
<img
src={marketplaceLogoImage}
alt={marketplace.marketplaceName[locale]}
title={marketplace.marketplaceName[locale]}
style={`background-color: ${marketplaceLogoImageAsset.metadata['background-color']}`}
/>
:
<img src={marketplaceLogoImage} alt={marketplace.marketplaceName[locale]} title={marketplace.marketplaceName[locale]} />
}
</a>
</li>
<style>
.marketplace-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);
text-align: center;
max-width: 12em;
}
.marketplace-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
padding: calc(0.5rem - 1px);
border-radius: 8px;
color: white;
background-color: #23262d;
opacity: 0.8;
}
.marketplace-card img {
max-width: 100%;
}
.marketplace-card h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.marketplace-card p {
margin-top: 0.5rem;
margin-bottom: 0;
}
.marketplace-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.marketplace-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent-light));
}
</style>

View File

@ -0,0 +1,254 @@
---
import type { ContentsDto } from "../data/internals/ContentsDtoT";
import type { Multilingual } from "../data/internals/MultilingualT";
import type { Product } from "../data/models/multis/Product";
import { getAssetById, getBrandsByIds, getMarketplacesByIds, getOffersByListingId } from "../data/api-client";
import path from "node:path";
import * as core from "../data/core/client";
import { renderMarkdown } from "../lib/rendering";
import { SCHEMAS } from "../data/models/schemas";
import type { Listing } from "../data/models/multis/Listing";
import type { Offer } from "../data/models/multis/Offer";
import type { Marketplace } from "../data/models/multis/Marketplace";
import ImageCarousel from "./ImageCarousel.astro";
import type { AmazonMarketplaceConnection } from "../data/models/components/AmazonMarketplaceConnection";
import { getSellersByIds } from "../data/api-client";
import { DateTime } from "luxon";
interface Props {
productDto: ContentsDto<Product>,
editToken?: string,
locale: string,
}
let category={ } as unknown as any;let site={ } as unknown as any
const formatAsCurrency = (amount: number) => amount.toLocaleString(locale, { style: 'currency', currency: 'USD' });
const { productDto, editToken, locale } = Astro.props;
const product = productDto.items[0].data!;
let amazonConnectorSchemaId = (await core.client.schemas.getSchema('product-marketplace-connection-amazon')).id;
let brandDto = (await getBrandsByIds(product.brand.iv[0]));
let possibleAmazonConnectors = product.marketplaceConnections.iv.filter((connection) => connection.connection.schemaId === amazonConnectorSchemaId);
let amazonConnector = possibleAmazonConnectors.length > 0 ? possibleAmazonConnectors[0].connection as AmazonMarketplaceConnection : undefined;
const listingsDto = await core.getContentsUsingJsonQuery<Listing>(SCHEMAS.LISTINGS, JSON.stringify({
filter: {
path: "data.product.iv",
op: "eq",
value: productDto.items[0].id,
}
}));
const listingOffersDtos = listingsDto.items.map(async (listingDto) => await core.getContentsUsingJsonQuery<Offer>(SCHEMAS.OFFERS, JSON.stringify({
filter: {
path: "data.listing.iv",
op: "eq",
value: listingDto.id
}
})));
// listingsDto.items.forEach(async (listing) => {
// let marketplaceId = listing.data?.marketplace.iv[0]!;
// const marketplaceDto = await core.getContentsByIds<Marketplace>(SCHEMAS.MARKETPLACES, marketplaceId);
// const marketplace = marketplaceDto.items[0].data!;
// pushDisclaimer({ renderedText: renderMarkdown(marketplace.disclaimer[locale]), marketplaceEditToken: marketplaceDto.items[0].editToken! });
// });
let productListingImages: string[] = [];
for (let listingDto of listingsDto.items) {
for (let assetId of listingDto.data?.marketplaceImages?.iv||[]) {
let assetDto = await getAssetById(assetId);
let assetUrl = path.posix.join('/img', assetDto.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
productListingImages.push(assetUrl);
}
}
let i18n: { [key: string]: Multilingual<string> } = {
'Brand:': {
'en-US': "Brand:",
'es-US': "Marca :",
'fr-CA': "Marque :",
},
'New from': {
'en-US': "New from",
'es-US': "Nuevo desde",
'fr-CA': "Nouveau depuis",
},
'Used from': {
'en-US': "Used from",
'es-US': "Usado desde",
'fr-CA': "Utilisé depuis",
},
'on': {
'en-US': "on",
'es-US': "en",
'fr-CA': "sur",
},
'Price information updated as of': {
'en-US': "Price information updated as of",
'es-US': "Información de precio actualizada a partir de",
'fr-CA': "Information sur le prix mise à jour jusqu\'à",
},
'Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.': {
'en-US': "Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.",
'es-US': "Precios de los productos y disponibilidad son exactos a la fecha/hora indicada y están sujetos a cambios. Cualquier información sobre precios y disponibilidad que se muestre en Amazon.com al momento del pago se aplicará a la compra de este producto.",
'fr-CA': "Les prix des produits et la disponibilité sont exacts à la date/heure indiquée et peuvent varier. Toute information sur les prix et la disponibilité affichés sur Amazon.com au moment de l'achat s'appliqueront à l'achat de ce produit."
}
}
---
<div class="callout">
<!-- <Fragment content={product.callout||category.description||site.categoriesCallout} /> -->
</div>
<div class="row">
<div class="col-sm-12 col-md-6 col-lg-4">
{ productListingImages.length == 1 &&
<img src={productListingImages[0]} alt={product.productName[locale]} style="max-width: 100%;" />
}
{ productListingImages.length > 1 &&
<ImageCarousel showDots={true} images={productListingImages.map((productUrl: string) => { return { src: productUrl }; } )||[]} />
}
</div>
<div class="col-sm-12 col-md-6 col-lg-8">
<h3 class="card-title">
<a href={amazonConnector?.siteStripeUrl||''}>{product?.productName[locale]}</a>
</h3>
<p>
<!-- <StarRating value={product?.amazonProductDetails?.reviewRating||0} max={5} overlayColor="#13151a" /> {product?.amazonProductDetails?.reviewCount} Reviews -->
{ brandDto && brandDto.items.length &&
// <span>&#x2022;</span>
<span class="item-metadata-key">{i18n['Brand:'][locale].toUpperCase()}</span> <span class="item-metadata-value"><a href={`/${brandDto.items[0].data!.slug[locale]}`}>{brandDto.items[0].data!.brandName[locale]}</a></span>
}
</p>
<div class="navbar">
{ listingsDto.items.map(async (listingDto) => {
let marketplacesDto = await getMarketplacesByIds(listingDto.data?.marketplace.iv[0]!);
let offersDto = await getOffersByListingId(listingDto.id);
return (
<Fragment>
{ offersDto.items.map(async (offerDto) => {
let sellersDto = await getSellersByIds(offerDto.data?.seller.iv[0]!);
return (
<span class="custom-btn-container">
{ offerDto.data?.newPrice.iv !== null &&
<a href={amazonConnector?.siteStripeUrl||''}>
{offerDto.data!.newPrice.iv ? formatAsCurrency(offerDto.data!.newPrice.iv||0) : 'See Price'} {i18n['New from'][locale]} {sellersDto.items[0].data?.sellerName[locale]} {i18n['on'][locale]} {marketplacesDto.items[0].data?.marketplaceName[locale]}
<span>&rarr;</span>
<br />
<small><small><i>{i18n['Price information updated as of'][locale]} {DateTime.fromISO(offerDto.data!.offerDate.iv).setLocale(locale).toFormat('D, t ZZZZ')}.</i></small></small>
</a>
}
{ offerDto.data?.usedPrice.iv &&
<a href={amazonConnector?.siteStripeUrl||''}>
{offerDto.data!.usedPrice.iv ? formatAsCurrency(offerDto.data!.usedPrice.iv||0) : 'See Price'} {i18n['Used from'][locale]} {sellersDto.items[0].data?.sellerName[locale]} {i18n['on'][locale]} {marketplacesDto.items[0].data?.marketplaceName[locale]}
<span>&rarr;</span><br />
<small><small><i>{i18n['Price information updated as of'][locale]} {DateTime.fromISO(offerDto.data!.offerDate.iv).setLocale(locale).toFormat('D, t ZZZZ')}.</i></small></small>
</a>
}
</span>
);
})}
{ marketplacesDto.items[0].data?.marketplaceName["en-US"] === 'Amazon' && locale === 'en-US' &&
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.']['en-US']}</i></small></small></p>
}
{ marketplacesDto.items[0].data?.marketplaceName["en-US"] === 'Amazon' && locale !== 'en-US' &&
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.']['en-US']}</i></small></small></p>
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.'][locale]}</i></small></small></p>
}
</Fragment>
)
})}
</div>
{ product?.description &&
<Fragment set:html={renderMarkdown(product.description[locale])} />
}
<!-- { !product?.description &&
<ul>
{product?.amazonProductDetails?.featureBullets?.map(featureBullet => (
<li>{featureBullet}</li>
))}
</ul>
<p>
{product?.amazonProductDetails?.description && product?.amazonProductDetails?.description}
</p>
} -->
<div class="navbar">
{ listingsDto.items.map(async (listingDto) => {
let marketplacesDto = await getMarketplacesByIds(listingDto.data?.marketplace.iv[0]!);
let offersDto = await getOffersByListingId(listingDto.id);
return (
<Fragment>
{ offersDto.items.map(async (offerDto) => {
let sellersDto = await getSellersByIds(offerDto.data?.seller.iv[0]!);
return (
<span class="custom-btn-container">
{ offerDto.data?.newPrice.iv !== null &&
<a href={amazonConnector?.siteStripeUrl||''}>
{offerDto.data!.newPrice.iv ? formatAsCurrency(offerDto.data!.newPrice.iv||0) : 'See Price'} {i18n['New from'][locale]} {sellersDto.items[0].data?.sellerName[locale]} {i18n['on'][locale]} {marketplacesDto.items[0].data?.marketplaceName[locale]}
<span>&rarr;</span><br />
<small>{i18n['Price information updated as of'][locale]} {DateTime.fromISO(offerDto.data!.offerDate.iv).setLocale(locale).toFormat('D, t ZZZZ')}.</small>
</a>
}
{ offerDto.data?.usedPrice.iv &&
<a href={amazonConnector?.siteStripeUrl||''}>
{offerDto.data!.usedPrice.iv ? formatAsCurrency(offerDto.data!.usedPrice.iv||0) : 'See Price'} {i18n['Used from'][locale]} {sellersDto.items[0].data?.sellerName[locale]} {i18n['on'][locale]} {marketplacesDto.items[0].data?.marketplaceName[locale]}
<span>&rarr;</span><br />
<small>{i18n['Price information updated as of'][locale]} {DateTime.fromISO(offerDto.data!.offerDate.iv).setLocale(locale).toFormat('D, t ZZZZ')}.</small>
</a>
}
</span>
);
})}
{ marketplacesDto.items[0].data?.marketplaceName["en-US"] === 'Amazon' && locale === 'en-US' &&
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.']['en-US']}</i></small></small></p>
}
{ marketplacesDto.items[0].data?.marketplaceName["en-US"] === 'Amazon' && locale !== 'en-US' &&
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.'][locale]}</i></small></small></p>
<p><small><small><i>{i18n['Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.']['en-US']}</i></small></small></p>
}
</Fragment>
)
})}
</div>
</div>
</div>
<style>
.custom-btn-container {
background-color: #23262d;
background-image: none;
background-size: 400%;
border-radius: 8px;
background-position: 100%;
padding: 1px;
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);
text-align: center;
display: flex;
align-items: center;
}
.custom-btn-container > a {
width: 100%;
border-radius: 8px;
text-decoration: none;
line-height: 1.4;
/* padding: calc(0.5rem - 1px); */
color: white;
padding: 1rem;
background-color: #23262d;
opacity: 0.8;
}
.custom-btn-container:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.navbar {
display: flex;
/* grid-template: auto; */
}
</style>

View File

@ -1,26 +1,76 @@
--- ---
import { type Product } from '../data/products/product'; // import { type Product } from '../data/api-models';
import type { ContentDto } from "../data/internals/ContentDtoT";
import type { Product } from "../data/models/multis/Product";
import CarouselSwiper from './CarouselSwiper'; import CarouselSwiper from './CarouselSwiper';
import StarRating from './StarRating.astro'; import StarRating from './StarRating.astro';
import * as core from "../data/core/client";
import { SCHEMAS } from "../data/models/schemas";
import type { Listing } from "../data/models/multis/Listing";
import type { Offer } from "../data/models/multis/Offer";
import { renderMarkdown } from "../lib/rendering";
import type { Marketplace } from "../data/models/multis/Marketplace";
import { getAssetById } from "../data/api-client";
import path from "node:path";
interface Props { interface Props {
product?: Product, productDto?: ContentDto<Product>,
locale: string,
editToken?: string,
} }
const { product } = Astro.props; const { productDto, locale, editToken } = Astro.props;
const product = productDto?.data;
const listingsDto = await core.getContentsUsingJsonQuery<Listing>(SCHEMAS.LISTINGS, JSON.stringify({
filter: {
path: "data.product.iv",
op: "eq",
value: productDto?.id
}
}));
const listingOffersDtos = listingsDto.items.map(async (listingDto) => await core.getContentsUsingJsonQuery<Offer>(SCHEMAS.OFFERS, JSON.stringify({
filter: {
path: "data.listing.iv",
op: "eq",
value: listingDto.id
}
})));
listingsDto.items.forEach(async (listing) => {
let marketplaceId = listing.data?.marketplace.iv[0]!;
const marketplaceDto = await core.getContentsByIds<Marketplace>(SCHEMAS.MARKETPLACES, marketplaceId);
const marketplace = marketplaceDto.items[0].data!;
// disclaimers.pushDisclaimer({ renderedText: renderMarkdown(marketplace.disclaimer[locale]), marketplaceEditToken: marketplaceDto.items[0].editToken! });
});
let productListingImages: string[] = [];
for (let listingDto of listingsDto.items) {
for (let assetId of listingDto.data?.marketplaceImages?.iv!||[]) {
let assetDto = await getAssetById(assetId);
let assetUrl = path.posix.join('/img', assetDto.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
productListingImages.push(assetUrl);
}
}
--- ---
<div class="product-card"> <div class="product-card" data-squidex-token={editToken}>
<a href={`/${product?.slug}`}> <a href={`/${product?.slug[locale]}`}>
<h4 class="card-header"> <h4 class="card-header">
{product?.name} {product?.productName[locale]}
</h4> </h4>
{product?.amazonProductDetails?.imageUrls !== undefined && product?.amazonProductDetails.imageUrls.length == 1 && <img src={product!.amazonProductDetails?.imageUrls[0]} alt={product?.amazonProductDetails?.title} style="width: 100%;" />} {productListingImages.length === 1 && <img src={productListingImages[0]} alt={product?.productName[locale]} style="width: 100%;" />}
{product?.amazonProductDetails?.imageUrls !== undefined && product?.amazonProductDetails.imageUrls.length > 1 && <CarouselSwiper client:load images={product?.amazonProductDetails.imageUrls.map((imageUrl) => { return { src: imageUrl }; })} /> } {productListingImages.length > 1 && <CarouselSwiper client:load images={productListingImages.map((imageUrl) => { return { src: imageUrl }; })} /> }
<div class="card-body"> <div class="card-body">
<StarRating value={product?.amazonProductDetails?.reviewRating||0} max={5} overlayColor="#23262d" /> {product?.amazonProductDetails?.reviewCount} Reviews <!-- <StarRating value={product?.amazonProductDetails?.reviewRating||0} max={5} overlayColor="#23262d" /> {product?.amazonProductDetails?.reviewCount} Reviews -->
<h5 class="card-title"> <h5 class="card-title">
{product?.title||product?.amazonProductDetails?.title} <span>&rarr;</span> {product?.productName[locale]} <span>&rarr;</span>
</h5> </h5>
</div> </div>
</a> </a>
@ -38,6 +88,7 @@ const { product } = Astro.props;
background-position: 100%; background-position: 100%;
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1); 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); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
max-width: 29em;
} }
.product-card > a { .product-card > a {
width: 100%; width: 100%;

View File

@ -0,0 +1,91 @@
---
import type { ProductCategory } from "../data/models/multis/ProductCategory";
import { getAssetById, getLocaleField } from "../data/api-client";
import path from "node:path";
import { renderMarkdown } from "../lib/rendering";
import Callout from "./Callout.astro";
interface Props {
productCategory: ProductCategory,
editToken?: string,
locale: string,
}
const { productCategory, editToken, locale } = Astro.props;
let productCategoryAsset = await getAssetById(productCategory.categoryImage[locale]);
let productCategoryImageBackgroundPosition = productCategoryAsset.metadata['background-position'] || 'center';
let productCategoryImage = path.posix.join('/img',
productCategoryAsset.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
---
<div class="product-category" data-squidex-token={editToken||''}>
<div class="cover-image"></div>
<div class="content center">
<div class="flex-right">
{getLocaleField(locale, productCategory.description) && <Fragment set:html={renderMarkdown(getLocaleField(locale, productCategory.description)!)} /></div> }
</div>
</div>
</div>
<style define:vars={{productCategoryImage: `url(${productCategoryImage})`, backgroundPosition: productCategoryImageBackgroundPosition }}>
.product-category {
margin-bottom: 2rem;
border: 1px solid rgba(var(--accent-light), 25%);
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
border-radius: 8px;
font-family: "Caveat", cursive;
font-weight: 400;
font-style: normal;
font-size: 2.2rem;
text-align: left;
}
.product-category .cover-image {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
background: var(--productCategoryImage);
background-position: var(--backgroundPosition);
background-size: cover;
min-height: 12em;
}
.product-category .flex {
display: flex;
gap: 0.8rem;
align-items: center;
}
.product-category .content {
padding: 0.5rem;
}
.product-category .after-flex p {
text-indent: 5em each-line;
color: pink;
}
.product-category .short-desc {
font-family: "Caveat", cursive;
font-size: 2.2rem;
}
.product-category .float-left {
justify-content: flex-start;
}
.product-category .flex-right {
justify-content: flex-start;
position: relative;
}
.product-category 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;
}
.product-category strong {
color: rgb(var(--accent-light));
/* font-weight: 800; */
}
</style>

View File

@ -0,0 +1,107 @@
---
import type { ProductCategory } from "../data/models/multis/ProductCategory";
import { getAssetById } from '../data/api-client';
import path from 'node:path';
import { renderMarkdown } from '../lib/rendering';
// import { BlocksRenderer, type BlocksContent } from '@strapi/blocks-react-renderer';
interface Props {
editToken: string,
productCategory: ProductCategory
locale: string,
}
const { editToken, productCategory, locale } = Astro.props;
let productCategoryAsset = await getAssetById(productCategory.categoryImage[locale]);
let productCategoryImageBackgroundPosition = productCategoryAsset.metadata['background-position'] || 'center';
let productCategoryImage = path.posix.join('/img',
productCategoryAsset.links['content']
.href
//The purpose of .split('/').reverse().filter(...2...).reverse().join('/') is to
//extract the last two directories from the end of the path, which we will
//use to form the path to the ../pages/img/[...imageLookup].astro handler, e.g.,
//in the form of `/img/${uuid}/${fileName}.${ext}`.
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/'));
const renderContext = { ...Astro.props, productCategoryImage }
---
<li class="category-card" data-squidex-token={editToken}>
<a href={`/${productCategory.slug[locale]}/`}>
<div class="cover-image"></div>
<div class="content">
<!-- <img src={productCategoryImage} alt={productCategory.categoryName[locale]} title={productCategory.categoryName[locale]} /> -->
<h2>
{productCategory.categoryName[locale]}&nbsp;<span>&rarr;</span>
</h2>
<Fragment set:html={renderMarkdown(productCategory.description[locale], renderContext)} />
</div>
</a>
</li>
<style define:vars={{productCategoryImage: `url(${productCategoryImage})`, backgroundPosition: productCategoryImageBackgroundPosition}}>
.category-card {
list-style: none;
display: grid;
background-color: #23262d;
background-image: none;
background-size: 400%;
border-radius: 8px;
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);
}
.category-card .cover-image {
left: 0;
top: 0;
min-height: 10em;
max-height: 18em;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
background: var(--productCategoryImage);
background-position: var(--backgroundPosition);
background-size: cover;
}
.category-card .content {
padding: 0.8rem;
}
.category-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
/* padding: calc(1.5rem - 1px); */
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
color: white;
background-color: #23262d;
opacity: 0.8;
}
.category-card img {
max-width: 100%;
width: 100%;
}
.category-card h2 {
margin: 0;
font-size: 1em;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.category-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.category-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent-light));
}
</style>
<style is:global>
.category-card p {
margin-block-end: 0em;
margin-block-start: 0.4em;
font-size: 0.8em;
}
</style>

114
src/components/Seller.astro Normal file
View File

@ -0,0 +1,114 @@
---
import type { Seller } from "../data/models/multis/Seller";
import { getAssetById, getLocaleField } from "../data/api-client";
import path from "node:path";
import { renderMarkdown } from "../lib/rendering";
interface Props {
seller: Seller,
editToken?: string,
locale: string,
}
const { seller, editToken, locale } = Astro.props;
let sellerLogoImageAsset = seller.logoImage[locale] ? await getAssetById(seller.logoImage[locale][0]) : undefined;
let sellerLogoImage = sellerLogoImageAsset ? path.posix.join('/img',
sellerLogoImageAsset.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/')) : '';
---
<div class="seller" data-squidex-token={editToken||''}>
<div class="flex">
{ sellerLogoImage ?
( sellerLogoImage &&
sellerLogoImageAsset?.metadata['background-color']
?
<img
src={sellerLogoImage}
alt={seller.sellerName[locale]}
title={seller.sellerName[locale]}
style={`background-color: ${sellerLogoImageAsset.metadata['background-color']}`}
/>
:
<img
src={sellerLogoImage}
alt={seller.sellerName[locale]}
title={seller.sellerName[locale]}
/>
) : seller.sellerName[locale]
}
</div>
</div>
<div class="seller-dark" data-squidex-token={editToken||''}>
<div class="flex">
{seller.sellerBio && seller.sellerBio[locale] && <div class="after-flex"><Fragment set:html={renderMarkdown(seller.sellerBio[locale]||'')} /></div> }
</div>
</div>
<style>
.seller, .seller-dark {
margin-bottom: 2rem;
border: 1px solid rgba(var(--accent-light), 25%);
padding: 0.5rem;
border-radius: 8px;
font-weight: 300;
font-style: normal;
text-align: left;
}
.seller {
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
}
.seller-dark {
padding: 1rem;
background-color: rgb(35, 38, 45);
}
.seller img {
min-width: 8rem;
max-width: 10rem;
border-radius: 7px;
}
.seller .flex {
display: flex;
gap: 0.8rem;
align-items: center;
}
.seller .after-flex p {
text-indent: 5em each-line;
color: pink;
}
.seller .short-desc {
font-family: "Caveat", cursive;
font-size: 2.2rem;
}
.seller .float-left {
justify-content: flex-start;
}
.seller .flex-right {
justify-content: flex-start;
position: relative;
}
.seller 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;
}
.seller strong {
color: rgb(var(--accent-light));
/* font-weight: 800; */
}
</style>
<style is:global>
.seller a, .seller a:link, .seller a:visited, .seller-dark a, .seller-dark a:link, .seller-dark a:visited {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,87 @@
---
import path from "node:path";
import type { Seller } from "../data/models/multis/Seller";
import { getAssetById } from "../data/api-client";
interface Props {
editToken?: string,
seller: Seller,
locale: string,
}
const { editToken, seller, locale } = Astro.props;
let sellerLogoImageAsset = seller.logoImage[locale] ? await getAssetById(seller.logoImage[locale][0]) : undefined;
let sellerLogoImage = sellerLogoImageAsset ? path.posix.join('/img',
sellerLogoImageAsset.links['content']
.href
.split('/')
.reverse()
.filter((_value, index, array) => index < (array.length - index - 2))
.reverse()
.join('/')) : '';
---
<li class="seller-card" data-squidex-token={editToken}>
<a href={`/${seller.slug[locale]}/`}>
{ sellerLogoImage ?
(sellerLogoImageAsset?.metadata['background-color']
?
<img
src={sellerLogoImage}
alt={seller.sellerName[locale]}
title={seller.sellerName[locale]}
style={`background-color: ${sellerLogoImageAsset.metadata['background-color']}`}
/>
:
<img src={sellerLogoImage} alt={seller.sellerName[locale]} title={seller.sellerName[locale]} />
) :
seller.sellerName[locale]
}
</a>
</li>
<style>
.seller-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);
text-align: center;
max-width: 12em;
}
.seller-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
padding: calc(0.5rem - 1px);
border-radius: 8px;
color: white;
background-color: #23262d;
opacity: 0.8;
}
.seller-card img {
max-width: 100%;
}
.seller-card h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.seller-card p {
margin-top: 0.5rem;
margin-bottom: 0;
}
.seller-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.seller-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent-light));
}
</style>

View File

@ -35,6 +35,14 @@
/* background-color: coral; */ /* background-color: coral; */
} }
.carousel-swiper .swiper-wrapper {
align-items: center !important;
}
.carousel-swiper .swiper-slide {
align-self: center !important;
}
.carousel-swiper .swiper-wrapper::selection, .carousel-swiper .overlay::selection, .carousel-swiper .swiper-slide::selection, .carousel-swiper .carousel-img::selection { .carousel-swiper .swiper-wrapper::selection, .carousel-swiper .overlay::selection, .carousel-swiper .swiper-slide::selection, .carousel-swiper .carousel-img::selection {
background-color: transparent; background-color: transparent;
color: transparent; color: transparent;
@ -61,15 +69,15 @@
background-color: rgba(35, 38, 45, 0.8); background-color: rgba(35, 38, 45, 0.8);
/* filter: invert(1); */ /* filter: invert(1); */
mix-blend-mode: hard-light; mix-blend-mode: hard-light;
height: 1.1em; height: 0.7em;
width: 1.1em; width: 0.7em;
vertical-align: middle;
} }
.carousel-swiper .swiper-pagination-bullet.swiper-pagination-bullet-active { .carousel-swiper .swiper-pagination-bullet.swiper-pagination-bullet-active {
background-color: rgba(225, 20, 4, 0.8); background-color: rgba(225, 20, 4, 0.8);
mix-blend-mode: hard-light; mix-blend-mode: hard-light;
padding: 0.9em; padding: 0.75rem;
margin-bottom: -0.3em;
} }
.carousel-swiper .swiper-button-next, .carousel-swiper .swiper-button-prev { .carousel-swiper .swiper-button-next, .carousel-swiper .swiper-button-prev {

View File

@ -0,0 +1,3 @@
---
---

View File

@ -11,27 +11,45 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
export interface ProcessEnv { export interface ProcessEnv {
AMAZON_PA_ACCESS_KEY?: string; // AMAZON_PA_ACCESS_KEY?: string;
AMAZON_PA_SECRET_KEY?: string; // AMAZON_PA_SECRET_KEY?: string;
AMAZON_PA_HOST?: string; // AMAZON_PA_HOST?: string;
AMAZON_PA_REGION?: string; // AMAZON_PA_REGION?: string;
AMAZON_PA_PARTNER_TYPE?: string; // AMAZON_PA_PARTNER_TYPE?: string;
AMAZON_PA_PARTNER_TAG?: string; // AMAZON_PA_PARTNER_TAG?: string;
GOOGLE_ADSENSE_ADS_TXT?: string; // GOOGLE_ADSENSE_ADS_TXT?: string;
GOOGLE_ANALYTICS_GTAG?: string; // GOOGLE_ANALYTICS_GTAG?: string;
SITE_URL?: string; SITE_URL?: string;
STRAPI_URL?: string;
STRAPI_API_TOKEN?: string;
PORT?: string;
WEBHOOK_PORT?: string;
SQUIDEX_APP_NAME?: string;
SQUIDEX_CLIENT_ID?: string;
SQUIDEX_CLIENT_SECRET?: string;
SQUIDEX_ENVIRONMENT?: string;
SQUIDEX_PUBLIC_URL?: string;
} }
export interface Config { export interface Config {
AmazonProductAdvertisingAPIAccessKey: string; // AmazonProductAdvertisingAPIAccessKey: string;
AmazonProductAdvertisingAPISecretKey: string; // AmazonProductAdvertisingAPISecretKey: string;
AmazonProductAdvertisingAPIHost: string; // AmazonProductAdvertisingAPIHost: string;
AmazonProductAdvertisingAPIRegion: string; // AmazonProductAdvertisingAPIRegion: string;
AmazonProductAdvertisingAPIPartnerType: string; // AmazonProductAdvertisingAPIPartnerType: string;
AmazonProductAdvertisingAPIPartnerTag: string; // AmazonProductAdvertisingAPIPartnerTag: string;
GoogleAdsenseAdsTxt: string; // GoogleAdsenseAdsTxt: string;
GoogleAnalyticsGTag: string; // GoogleAnalyticsGTag: string;
siteUrl: string; siteUrl: string;
strapiUrl: string;
strapiApiToken: string;
port: number;
webhookPort: number;
squidexAppName?: string;
squidexClientId?: string;
squidexClientSecret?: string;
squidexEnvironment?: string;
squidexPublicUrl?: string;
} }
const env: ProcessEnv = {}; const env: ProcessEnv = {};
@ -44,18 +62,42 @@ dotEnvConfig = dotenvExpand.expand({
processEnv: process.env as dotenvExpand.DotenvParseInput processEnv: process.env as dotenvExpand.DotenvParseInput
}); });
export const getAmazonProductAdvertisingAPIAccessKey = () => (env.AMAZON_PA_ACCESS_KEY||``).trim(); // export const getAmazonProductAdvertisingAPIAccessKey = () => (env.AMAZON_PA_ACCESS_KEY||``).trim();
export const getAmazonProductAdvertisingAPISecretKey = () => (env.AMAZON_PA_SECRET_KEY||``).trim(); // export const getAmazonProductAdvertisingAPISecretKey = () => (env.AMAZON_PA_SECRET_KEY||``).trim();
export const getAmazonProductAdvertisingAPIHost = () => (env.AMAZON_PA_HOST||``).trim(); // export const getAmazonProductAdvertisingAPIHost = () => (env.AMAZON_PA_HOST||``).trim();
export const getAmazonProductAdvertisingAPIRegion = () => (env.AMAZON_PA_REGION||``).trim(); // export const getAmazonProductAdvertisingAPIRegion = () => (env.AMAZON_PA_REGION||``).trim();
export const getAmazonProductAdvertisingAPIPartnerType = () => (env.AMAZON_PA_PARTNER_TYPE||`Associate`).trim(); // export const getAmazonProductAdvertisingAPIPartnerType = () => (env.AMAZON_PA_PARTNER_TYPE||`Associate`).trim();
export const getAmazonProductAdvertisingAPIPartnerTag = () => (env.AMAZON_PA_PARTNER_TAG||``).trim(); // export const getAmazonProductAdvertisingAPIPartnerTag = () => (env.AMAZON_PA_PARTNER_TAG||``).trim();
export const getGoogleAnalyticsGtag = () => (env.GOOGLE_ANALYTICS_GTAG||``).trim(); // export const getGoogleAnalyticsGtag = () => (env.GOOGLE_ANALYTICS_GTAG||``).trim();
export const getGoogleAdsenseAdsTxt = () => (env.GOOGLE_ADSENSE_ADS_TXT||``).trim()||`google.com, pub-1234567890abcdef, DIRECT, fedcba9876543210`; // export const getGoogleAdsenseAdsTxt = () => (env.GOOGLE_ADSENSE_ADS_TXT||``).trim()||`google.com, pub-1234567890abcdef, DIRECT, fedcba9876543210`;
export const getSiteUrl = () => trimSlashes(env.SITE_URL||`http://localhost`); export const getSiteUrl = () => trimSlashes(env.SITE_URL||`http://localhost`);
export const getStrapiUrl = () => trimSlashes(env.STRAPI_URL||`http://localhost:1337`);
export const getStrapiApiToken = () => trimSlashes(env.STRAPI_API_TOKEN!);
export const getPort = () => env.PORT ? parseInt(env.PORT!) : 4321;
export const getWebhookPort = () => env.WEBHOOK_PORT ? parseInt(env.WEBHOOK_PORT!) : 3210;
export const getSquidexAppName = () => env.SQUIDEX_APP_NAME || undefined;
export const getSquidexClientId = () => env.SQUIDEX_CLIENT_ID || undefined;
export const getSquidexClientSecret = () => env.SQUIDEX_CLIENT_SECRET || undefined;
export const getSquidexEnvironment = () => env.SQUIDEX_ENVIRONMENT || undefined;
export const getSquidexPublicUrl = () => env.SQUIDEX_PUBLIC_URL || getSquidexEnvironment();
export const config: Config = { export const config: Config = {
GoogleAnalyticsGTag: getGoogleAnalyticsGtag(), // AmazonProductAdvertisingAPIAccessKey: getAmazonProductAdvertisingAPIAccessKey(),
GoogleAdsenseAdsTxt: getGoogleAdsenseAdsTxt(), // AmazonProductAdvertisingAPIHost: getAmazonProductAdvertisingAPIHost(),
// AmazonProductAdvertisingAPIPartnerTag: getAmazonProductAdvertisingAPIPartnerTag(),
// AmazonProductAdvertisingAPIPartnerType: getAmazonProductAdvertisingAPIPartnerType(),
// AmazonProductAdvertisingAPIRegion: getAmazonProductAdvertisingAPIRegion(),
// AmazonProductAdvertisingAPISecretKey: getAmazonProductAdvertisingAPISecretKey(),
// GoogleAnalyticsGTag: getGoogleAnalyticsGtag(),
// GoogleAdsenseAdsTxt: getGoogleAdsenseAdsTxt(),
siteUrl: getSiteUrl(), siteUrl: getSiteUrl(),
strapiApiToken: getStrapiApiToken(),
strapiUrl: getStrapiUrl(),
port: getPort(),
webhookPort: getWebhookPort(),
squidexAppName: getSquidexAppName(),
squidexClientId: getSquidexClientId(),
squidexClientSecret: getSquidexClientSecret(),
squidexEnvironment: getSquidexEnvironment(),
squidexPublicUrl: getSquidexPublicUrl(),
}; };

323
src/data/api-client.ts Normal file
View File

@ -0,0 +1,323 @@
import * as core from "./core/client";
import { SCHEMAS } from "./models/schemas";
import { getContents } from "./core/client.js";
import { SupportedLocales, type Multilingual } from "./internals/MultilingualT";
import type { Component } from "./internals/Component";
import type { Brand } from "./models/multis/Brand";
import type { Page } from "./models/multis/Page";
import type { Site } from "./models/singles/Site";
import type { SiteConfig } from "./models/singles/SiteConfig";
import type { Marketplace } from "./models/multis/Marketplace";
import type { ProductCategory } from "./models/multis/ProductCategory";
import type { Product } from "./models/multis/Product";
import type { Slug } from "./models/multis/Slug";
import type { Seller } from "./models/multis/Seller";
import type { NonMultilingual } from "./internals/NonMultilingualT";
import type { ContentsDto } from "./internals/ContentsDtoT";
import type { ContentData } from "@squidex/squidex/api/types/ContentData";
import type { ContentDto } from "./internals/ContentDtoT";
import type { Listing } from "./models/multis/Listing.js";
import type { Offer } from "./models/multis/Offer.js";
/** Generic helpers */
export const getLocaleField = function <T>(locale: SupportedLocales|string, field: Multilingual<T>) {
if (field && field[locale.toString()])
return field[locale.toString()];
}
export function getPageComponentOfType<T extends Component>(component: Component) {
return component as T;
}
/** Assets handlers */
export const getAssetById = core.getAssetById;
/** Brands handlers */
export const getBrandsByIds = async (ids: string) =>
await core.getContentsByIds<Brand>(SCHEMAS.BRANDS, ids);
export const getBrandsByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<Brand>(SCHEMAS.BRANDS, forLang, slug);
export const getBrandsUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Brand>(SCHEMAS.BRANDS, jsonQuery);
/** Marketplaces handlers */
export const getMarketplacesByIds = async (ids: string) =>
await core.getContentsByIds<Marketplace>(SCHEMAS.MARKETPLACES, ids);
export const getMarketplacesByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<Marketplace>(SCHEMAS.MARKETPLACES, forLang, slug);
export const getMarketplacesUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Marketplace>(SCHEMAS.MARKETPLACES, jsonQuery);
/** Marketplaces handlers */
export const getSellersByIds = async (ids: string) =>
await core.getContentsByIds<Seller>(SCHEMAS.SELLERS, ids);
export const getSellersByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<Seller>(SCHEMAS.SELLERS, forLang, slug);
export const getSellersUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Seller>(SCHEMAS.SELLERS, jsonQuery);
/** Pages handlers */
export const getPagesByIds = async (ids: string) =>
await core.getContentsByIds<Page>(SCHEMAS.PAGES, ids);
export const getPagesByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<Page>(SCHEMAS.PAGES, forLang, slug);
export const getPagesUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Page>(SCHEMAS.PAGES, jsonQuery);
/** Product Categories handlers */
export const getProductCategoriesByIds = async (ids: string) =>
await core.getContentsByIds<ProductCategory>(SCHEMAS.PRODUCT_CATEGORIES, ids);
export const getProductCategoriesByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<ProductCategory>(SCHEMAS.PRODUCT_CATEGORIES, forLang, slug);
export const getProductCategoriesUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<ProductCategory>(SCHEMAS.PRODUCT_CATEGORIES, jsonQuery);
/** Products handlers */
export const getProductsByIds = async (ids: string) =>
await core.getContentsByIds<Product>(SCHEMAS.PRODUCTS, ids);
export const getProductsByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsByLangSlug<Product>(SCHEMAS.PRODUCTS, forLang, slug);
export const getProductsUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Product>(SCHEMAS.PRODUCTS, jsonQuery);
/** Product Listings handlers */
export const getProductListingsByIds = async (ids: string) =>
await core.getContentsByIds<Listing>(SCHEMAS.LISTINGS, ids);
export const getProductListingsUsingJsonQuery = async (jsonQuery: string|undefined = undefined) =>
await core.getContentsUsingJsonQuery<Listing>(SCHEMAS.LISTINGS, jsonQuery);
/** Offers handlers */
export const getOffersByListingId = async (listingId: string) =>
await core.getContentsUsingJsonQuery<Offer>(SCHEMAS.OFFERS, JSON.stringify({
filter: {
path: "data.listing.iv",
op: "eq",
value: listingId
}
}));
/** Slugs handlers */
export const getAllSlugs = async () =>
await core.getContents<Slug>(SCHEMAS.SLUGS);
export const getSlugByLangSlug = async (forLang: SupportedLocales|string, slug: string) =>
await core.getContentsUsingJsonQuery<Slug>(SCHEMAS.SLUGS, JSON.stringify({
filter: {
and: [
{ path: `data.locale.iv`, op: 'eq', value: forLang },
{ path: `data.localizedSlug.iv`, op: 'eq', value: slug }
]
}
}));
/** Site handlers */
export const getSite = async () =>
await getContents<Site>(SCHEMAS.SITE);
export const getSiteHomePage = async (site: Site) => {
if (site.homePage && site.homePage.iv.length > 0) {
let homePageIds: string[] = site!.homePage.iv;
let pageContents = getPagesByIds(homePageIds[0]);
return pageContents;
}
throw new Error('No site home page exists.');
}
export const getSiteConfig = async () =>
await getContents<SiteConfig>(SCHEMAS.SITE_CONFIG);
export async function performSyncLocalizedSlugs(logFn = console.log) {
logFn("[sync-slugs] Begin sync localized slugs.")
let allSlugs = await getAllSlugs();
let allPages = await core.getContentsUsingJsonQuery<Page>(SCHEMAS.PAGES);
let allBrands = await core.getContentsUsingJsonQuery<Brand>(SCHEMAS.BRANDS);
let allProducts = await core.getContentsUsingJsonQuery<Product>(SCHEMAS.PRODUCTS);
let allProductCategories = await core.getContentsUsingJsonQuery<ProductCategory>(SCHEMAS.PRODUCT_CATEGORIES);
let allSellers = await core.getContentsUsingJsonQuery<Seller>(SCHEMAS.SELLERS);
let allMarketplaces = await core.getContentsUsingJsonQuery<Marketplace>(SCHEMAS.MARKETPLACES);
const locales = Object.values(SupportedLocales);
const findSlugInMultilingual = function<T>(slug: Slug, schema: SCHEMAS|string, contents: ContentsDto<T>) {
for (let i = 0; i < contents.items.length; i++) {
let item = contents.items[i];
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let testSlug = (item.data! as any).slug[locale]
if (testSlug) {
if (slug.locale.iv === locale
&& slug.localizedSlug.iv === testSlug
&& slug.referenceSchema.iv === schema
&& slug.reference.iv.length === 1
&& slug.reference.iv[0] === item.id) {
return item;
}
}
}
}
}
const findSlugInSlugs = function(locale: SupportedLocales|string, slug: Multilingual<string>, schema: SCHEMAS|string, referenceId: string) {
for (let i = 0; i < allSlugs.items.length; i++) {
let testSlug = allSlugs.items[i].data!;
if (testSlug.localizedSlug.iv === slug[locale]
&& testSlug.locale.iv === locale
&& testSlug.referenceSchema.iv === schema
&& testSlug.reference.iv.length === 1
&& testSlug.reference.iv[0] === referenceId) {
return allSlugs.items[i];
}
}
}
let batchAddSlugsQueue: Slug[] = [];
allPages.items.forEach((page) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (page.data! as Page).slug, SCHEMAS.PAGES, page.id);
if (!foundSlugDto) {
//cache slug for page
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (page.data! as Page).slug[locale] },
referenceSchema: { iv: SCHEMAS.PAGES },
reference: { iv: [page.id] }
});
}
}
});
allBrands.items.forEach((brand) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (brand.data! as Brand).slug, SCHEMAS.BRANDS, brand.id);
if (!foundSlugDto) {
//cache slug for brand
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (brand.data! as Brand).slug[locale] },
referenceSchema: { iv: SCHEMAS.BRANDS },
reference: { iv: [brand.id] }
});
}
}
});
allProducts.items.forEach((product) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (product.data! as Product).slug, SCHEMAS.PRODUCTS, product.id);
if (!foundSlugDto) {
//cache slug for product
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (product.data! as Product).slug[locale] },
referenceSchema: { iv: SCHEMAS.PRODUCTS },
reference: { iv: [product.id] }
});
}
}
});
allProductCategories.items.forEach((productCategory) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (productCategory.data! as ProductCategory).slug, SCHEMAS.PRODUCT_CATEGORIES, productCategory.id);
if (!foundSlugDto) {
//cache slug for product category
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (productCategory.data! as ProductCategory).slug[locale] },
referenceSchema: { iv: SCHEMAS.PRODUCT_CATEGORIES },
reference: { iv: [productCategory.id] }
});
}
}
});
allSellers.items.forEach((seller) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (seller.data! as Seller).slug, SCHEMAS.SELLERS, seller.id);
if (!foundSlugDto) {
//cache slug for product category
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (seller.data! as Seller).slug[locale] },
referenceSchema: { iv: SCHEMAS.SELLERS },
reference: { iv: [seller.id] }
});
}
}
});
allMarketplaces.items.forEach((marketplace) => {
for (let l = 0; l < locales.length; l++) {
let locale = locales[l];
let foundSlugDto = findSlugInSlugs(locale, (marketplace.data! as Marketplace).slug, SCHEMAS.MARKETPLACES, marketplace.id);
if (!foundSlugDto) {
//cache slug for product category
batchAddSlugsQueue.push({
locale: { iv: locale },
localizedSlug: { iv: (marketplace.data! as Marketplace).slug[locale] },
referenceSchema: { iv: SCHEMAS.MARKETPLACES },
reference: { iv: [marketplace.id] }
});
}
}
});
let batchRemoveSlugsQueue: string[] = [];
allSlugs.items.forEach((slugDto) => {
const doesSlugExistInPages = findSlugInMultilingual<Page>(slugDto.data! as Slug, SCHEMAS.PAGES, allPages);
const doesSlugExistInBrands = findSlugInMultilingual<Brand>(slugDto.data! as Slug, SCHEMAS.BRANDS, allBrands);
const doesSlugExistInProducts = findSlugInMultilingual<Product>(slugDto.data! as Slug, SCHEMAS.PRODUCTS, allProducts);
const doesSlugExistInProductCategories = findSlugInMultilingual<ProductCategory>(slugDto.data! as Slug, SCHEMAS.PRODUCT_CATEGORIES, allProductCategories);
const doesSlugExistInSellers = findSlugInMultilingual<Seller>(slugDto.data! as Slug, SCHEMAS.SELLERS, allSellers);
const doesSlugExistInMarketplaces = findSlugInMultilingual<Marketplace>(slugDto.data! as Slug, SCHEMAS.MARKETPLACES, allMarketplaces);
const doesSlugExistElsewhere = doesSlugExistInPages||doesSlugExistInBrands||doesSlugExistInProducts||doesSlugExistInProductCategories||doesSlugExistInSellers||doesSlugExistInMarketplaces;
const shouldPruneOrphanSlug = !doesSlugExistElsewhere;
if (shouldPruneOrphanSlug) {
//prune orphan slugs from cache
batchRemoveSlugsQueue.push(slugDto.id);
}
});
const MAX_TIME_TO_POST_SLUGS = 60;//s
logFn("[sync-slugs] Add", batchAddSlugsQueue.length, "slugs");
let bulkAddResult = await core.client.contents.postContents(SCHEMAS.SLUGS, { datas: batchAddSlugsQueue as unknown as ContentData[], publish: true }, { timeoutInSeconds: MAX_TIME_TO_POST_SLUGS });
logFn("[sync-slugs] Remove by id", batchRemoveSlugsQueue.length, "slugs");
batchRemoveSlugsQueue.forEach(async (removeId) => {
await core.client.contents.deleteContent(SCHEMAS.SLUGS, removeId)
})
logFn("[sync-slugs] Finish sync localized slugs.")
}
export class AmazonPAApiSyncClient {
public async getSyncProducts () {
let amazonSlug = `${SupportedLocales["en-US"]}/amazon`;
let amazonMarketplaceId = (await core.getContentsByLangSlug<Marketplace>(SCHEMAS.MARKETPLACES, SupportedLocales['en-US'], amazonSlug)).items[0].id;
let allProducts = await core.getContentsUsingJsonQuery<Product>(SCHEMAS.PRODUCTS);
return allProducts.items.filter((product) => product.data?.marketplaceConnections.iv[0].marketplace[0] === amazonMarketplaceId);
};
public async getLastSync (productId: string) {
}
}
// console.log(await (new AmazonPAApiSyncClient()).getSyncProducts());

41
src/data/core/client.ts Normal file
View File

@ -0,0 +1,41 @@
import { config } from "../../config.js";
import { SquidexClient } from "@squidex/squidex";
import type { ContentsDto } from "../internals/ContentsDtoT.js";
import type { SupportedLocales } from "../internals/MultilingualT.js";
import type { SCHEMAS } from "../models/schemas.js";
export const client = new SquidexClient({
appName: config.squidexAppName!,
clientId: config.squidexClientId!,
clientSecret: config.squidexClientSecret!,
environment: config.squidexEnvironment!,
tokenStore: new SquidexClient.InMemoryTokenStore(),
// tokenStore: new SquidexStorageTokenStore() // Keep the tokens in the local store.
// tokenStore: new SquidexStorageTokenStore(sessionStorage, "CustomKey")
});
export const TIMEOUT_IN_SECONDS = 10;
/** Asset Handling */
export const getAssetById = async (assetId: string) => (
await client.assets.getAsset(assetId, {timeoutInSeconds: TIMEOUT_IN_SECONDS})
);
/** Generic Content Handling */
export const getContents = async <T>(schema: SCHEMAS|string) => (
await client.contents.getContents(schema, { }, { timeoutInSeconds: TIMEOUT_IN_SECONDS })
) as ContentsDto<T>;
export const getContentsByIds = async <T>(schema: SCHEMAS|string, ids: string) => (
await client.contents.getContents(schema, { ids }, { timeoutInSeconds: TIMEOUT_IN_SECONDS })
) as ContentsDto<T>;
export const getContentsUsingJsonQuery = async <T>(schema: SCHEMAS|string, jsonQuery: string|undefined = undefined) => (
await client.contents.getContents(schema, { q: jsonQuery }, { timeoutInSeconds: TIMEOUT_IN_SECONDS })
) as ContentsDto<T>;
export const getContentsByLangSlug = async <T>(schema: SCHEMAS|string, forLang: SupportedLocales|string, slug: string) => (
await getContentsUsingJsonQuery<T>(schema, JSON.stringify({ filter: { path: `data.slug.${forLang}`, op: 'eq', value: slug }}))
);

View File

@ -0,0 +1,43 @@
//Changed my mind on using this RichText junk
// export enum RichTextNodeType {
// doc = 'doc',
// text = 'text',
// link = 'link',
// }
// export interface RichTextBase {
// type: RichTextNodeType,
// }
// export class RichText implements RichTextBase {
// public type: RichTextNodeType;
// public text: string;
// public marks?: []
// public constructor(text: string) {
// this.type = RichTextNodeType.text;
// this.text = text;
// }
// }
// export class RichTextDoc implements RichTextBase {
// public type: RichTextNodeType;
// public content: RichTextBase[];
// public constructor(content: RichTextBase[] = []) {
// this.type = RichTextNodeType.doc;
// this.content = content;
// }
// }
// export class RichTextParagraph implements RichTextBase {
// public type: RichTextNodeType;
// public content: RichTextBase[];
// public constructor(content: RichTextBase[] = []) {
// this.type = RichTextNodeType.doc;
// this.content = content;
// }
// }
// export interface PageNotFoundError extends Error {
// results: any,
// }

View File

@ -0,0 +1,4 @@
export interface Component {
schemaId?: string,
schemaName?: string,
}

View File

@ -0,0 +1,5 @@
import { Squidex } from "@squidex/squidex";
export interface ContentDto<T> extends Squidex.ContentDto {
data?: T;
}

View File

@ -0,0 +1,7 @@
import type { ContentDto } from "./ContentDtoT";
import { Squidex } from "@squidex/squidex";
export interface ContentsDto<T> extends Squidex.ContentsDto {
/** The generic content items. */
items: ContentDto<T>[];
}

View File

@ -0,0 +1,12 @@
export enum SupportedLocales {
'en-US' = 'en-US',
'es-US' = 'es-US',
'fr-CA' = 'fr-CA',
};
export interface Multilingual<T> {
[key: string]: T,
'en-US': T,
'es-US': T,
'fr-CA': T,
};

View File

@ -0,0 +1,4 @@
export interface NonMultilingual<T> {
[key: string]: T,
iv: T,
}

View File

@ -0,0 +1,7 @@
import type { NonMultilingual } from "../../internals/NonMultilingualT";
import type { MarketplaceConnection } from "./MarketplaceConnection";
export interface AmazonMarketplaceConnection extends MarketplaceConnection {
asin: string,
siteStripeUrl: string,
}

View File

@ -0,0 +1,13 @@
import type { Component } from "../../internals/Component";
export interface AmazonPAConfig extends Component {
schemaId: string,
accessKey: string,
secretKey: string,
partnerType: string,
partnerTag: string,
marketplace: string,
service: string,
host: string,
region: string,
};

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface AmazonPAGetItemsRequest extends Component {
ItemIds: { ItemId: string }[],
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface GoogleAdSense extends Component {
adsTxt: string,
};

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface GoogleAnalytics extends Component {
gTag: string,
};

View File

@ -0,0 +1,3 @@
import type { Component } from "../../internals/Component";
export interface MarketplaceConnection extends Component {}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageBrand extends Component {
brand: string[],
}

View File

@ -0,0 +1,6 @@
import type { Component } from "../../internals/Component";
import type { Multilingual } from "../../internals/MultilingualT";
export interface PageCallout extends Component {
text: string,
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageContent extends Component {
content: string,
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageMarketplace extends Component {
marketplace: string[],
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageProduct extends Component {
product: string[],
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageProductCategory extends Component {
productCategory: string[],
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface PageSeller extends Component {
seller: string[],
}

View File

@ -0,0 +1,9 @@
import type { Component } from "../../internals/Component";
export interface PageSeo extends Component {
metaTitle: string,
metaDescription: string,
metaImage: string,
keywords: string,
metaSocial: [],
}

View File

@ -0,0 +1,8 @@
import type { Component } from "../../internals/Component";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
import type { MarketplaceConnection } from "./MarketplaceConnection";
export interface ProductMarketplaceConnection extends Component {
marketplace: string[],
connection: MarketplaceConnection,
}

View File

@ -0,0 +1,5 @@
import type { Component } from "../../internals/Component";
export interface QueryComponent extends Component {
jsonQuery?: any,
}

View File

@ -0,0 +1,9 @@
import type { AmazonPAGetItemsRequest } from "../components/AmazonPAGetItemsRequest";
import type { GetItemsResponse } from "amazon-pa-api5-node-ts";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface AmazonGetItem {
requestDate: NonMultilingual<string>,
getItemsRequest: NonMultilingual<AmazonPAGetItemsRequest>,
apiResponse: NonMultilingual<GetItemsResponse>,
}

View File

@ -0,0 +1,11 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Brand {
brandName: Multilingual<string>,
logoImage?: Multilingual<string>,
slug: Multilingual<string>,
shortDescription?: Multilingual<string>,
longDescription?: Multilingual<string>,
brandPage?: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,9 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Listing {
product: NonMultilingual<string[]>,
marketplace: NonMultilingual<string[]>,
marketplaceDescription: Multilingual<string>,
marketplaceImages: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,12 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Marketplace {
marketplaceName: Multilingual<string>,
slug: Multilingual<string>,
logoImage: Multilingual<string[]>,
marketplacePage: NonMultilingual<string[]>,
disclaimer: Multilingual<string>,
shortDescription: Multilingual<string>,
longDescription: Multilingual<string>,
}

View File

@ -0,0 +1,9 @@
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Offer {
offerDate: NonMultilingual<string>,
seller: NonMultilingual<string[]>,
listing: NonMultilingual<string[]>,
newPrice: NonMultilingual<number|null>,
usedPrice: NonMultilingual<number|null>,
}

View File

@ -0,0 +1,12 @@
import type { PageSeo } from "../components/PageSeo";
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
import type { Component } from "../../internals/Component";
export interface Page {
title: Multilingual<string>,
slug: Multilingual<string>,
content: Multilingual<Component[]>,
seo: Multilingual<PageSeo>,
parentPage: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,14 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
import type { ProductMarketplaceConnection } from "../components/ProductMarketplaceConnection";
export interface Product {
productName: Multilingual<string>,
slug: Multilingual<string>,
brand: NonMultilingual<string[]>,
categories: NonMultilingual<string[]>,
tags: Multilingual<string[]>,
description: Multilingual<string>,
marketplaceConnections: NonMultilingual<ProductMarketplaceConnection[]>,
productPage?: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,10 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface ProductCategory {
categoryName: Multilingual<string>,
slug: Multilingual<string>,
categoryImage: Multilingual<string>,
description: Multilingual<string>,
parentCategory: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,8 @@
import type { Multilingual } from "../../internals/MultilingualT";
export interface Seller {
sellerName: Multilingual<string>,
sellerBio: Multilingual<string>,
slug: Multilingual<string>,
logoImage: Multilingual<string[]>,
}

View File

@ -0,0 +1,10 @@
import type { SCHEMAS } from "../schemas";
import type { SupportedLocales } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Slug {
locale: NonMultilingual<SupportedLocales|string>,
localizedSlug: NonMultilingual<string>,
referenceSchema: NonMultilingual<SCHEMAS|string[]>,
reference: NonMultilingual<string[]>,
}

View File

@ -0,0 +1,15 @@
export const enum SCHEMAS {
AMAZON_GET_ITEMS = 'amazon-get-items',
BRANDS = 'brands',
PAGES = 'pages',
LISTINGS = 'listings',
MARKETPLACES = 'marketplaces',
OFFERS = 'offers',
PRODUCT_CATEGORIES = 'product-categories',
PRODUCTS = 'products',
SOCIAL_NETWORKS = 'social-networks',
SELLERS = 'sellers',
SITE = 'site',
SITE_CONFIG = 'site-config',
SLUGS = 'slugs',
};

View File

@ -0,0 +1,11 @@
import type { Multilingual } from "../../internals/MultilingualT";
import type { NonMultilingual } from "../../internals/NonMultilingualT";
export interface Site {
siteName: Multilingual<string>,
siteUrl: Multilingual<string>,
homePage: NonMultilingual<string[]>,
siteBrand: Multilingual<string>,
copyright: Multilingual<string>,
footerLinks: Multilingual<string>,
}

View File

@ -0,0 +1,10 @@
import type { NonMultilingual } from "../../internals/NonMultilingualT";
import type { AmazonPAConfig } from "../components/AmazonPAConfig";
import type { GoogleAdSense } from "../components/GoogleAdSense";
import type { GoogleAnalytics } from "../components/GoogleAnalytics";
export interface SiteConfig {
amazonPAAPIConfig: NonMultilingual<AmazonPAConfig>,
googleAdSense: NonMultilingual<GoogleAdSense>,
googleAnalytics: NonMultilingual<GoogleAnalytics>,
}

13
src/env.d.ts vendored
View File

@ -1 +1,14 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" /> /// <reference types="astro/client" />
interface ImportMetaEnv {
readonly SITE_URL?: string;
readonly STRAPI_URL?: string;
readonly STRAPI_API_TOKEN?: string;
readonly PORT?: number;
readonly WEBHOOK_PORT?: number;
readonly SQUIDEX_APP_NAME?: string;
readonly SQUIDEX_CLIENT_ID?: string;
readonly SQUIDEX_CLIENT_SECRET?: string;
readonly SQUIDEX_ENVIRONMENT?: string;
readonly SQUIDEX_PUBLIC_URL?: string;
}

View File

@ -2,25 +2,65 @@
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import bootstrap from "bootstrap/dist/js/bootstrap.min.js?url"; import bootstrap from "bootstrap/dist/js/bootstrap.min.js?url";
import { config } from "../config"; import { config } from "../config";
import { getLocaleField } from "../data/api-client";
import type { Page } from "../data/models/multis/Page";
import type { Site } from "../data/models/singles/Site";
import type { SiteConfig } from "../data/models/singles/SiteConfig";
import { interpolateString, renderMarkdown } from '../lib/rendering';
interface Props { interface Props {
locale: string;
page: Page;
title: string; title: string;
site: Site;
siteEditToken: string;
shouldEmbedSquidexSDK: boolean;
siteConfig: SiteConfig,
} }
const { title } = Astro.props; const { locale, page, site, siteConfig, siteEditToken, title, shouldEmbedSquidexSDK } = Astro.props;
const gTag = config.GoogleAnalyticsGTag;
const lang = locale.substring(0, locale.indexOf('-'));
const renderContext = { ...Astro.props, lang, shouldEmbedSquidexSDK };
//template adjustment for site to work with layers of squidEx
const cssFixSquidex = `
<style>
/*.squidex-overlay-border,
.squidex-overlay-toolbar,
.squidex-overlay-links {
pointer-events: none;
}*/
.link-card-grid {
padding: 0.5rem !important;
transition: 0.5s;
}
.link-card-grid:hover,
.link-card-grid a:hover ,
.squidex:hover ~ .link-card-grid,
.squidex-overlay:hover ~ .link-card-grid,
.squidex-overlay-border:hover ~ .link-card-grid,
.squidex-overlay-toolbar:hover ~ .link-card-grid,
.squidex-overlay-links:hover ~ .link-card-grid,
.squidex-overlay-links a:hover ~ .link-card-grid,
.squidex-overlay-links .squidex-overlay-links a:hover ~ .link-card-grid,
.squidex-overlay-schema:hover ~ .link-card-grid
{
padding: 0.5rem !important;
}
</style>
`.replace(/\s*/g, '');
--- ---
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang={lang}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="description" content="Astro description" /> <title>{title ? `${title} - ${getLocaleField(locale, site.siteName)!}` : `${site.siteName}`}</title>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width, initial-scale=0.45" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<meta name="description" content="Your one-stop shop for all your after-market Dasher supplies." /> <slot name="head" />
<title>{title}</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js" integrity="sha256-sw0iNNXmOJbQhYFuC9OF2kOlD5KQKe1y5lfBn4C9Sjg=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js" integrity="sha256-sw0iNNXmOJbQhYFuC9OF2kOlD5KQKe1y5lfBn4C9Sjg=" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.9" integrity="sha384-QFjmbokDn2DjBjq+fM+8LUIVrAgqcNW2s0PjAxHETgRn9l4fvX31ZxDxvwQnyMOX" crossorigin="anonymous"></script> <script src="https://unpkg.com/htmx.org@1.9.9" integrity="sha384-QFjmbokDn2DjBjq+fM+8LUIVrAgqcNW2s0PjAxHETgRn9l4fvX31ZxDxvwQnyMOX" crossorigin="anonymous"></script>
@ -31,7 +71,7 @@ const gTag = config.GoogleAnalyticsGTag;
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<!-- Google tag (gtag.js) --> <!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-EB3FP1LR55"></script> <script async src="https://www.googletagmanager.com/gtag/js?id=G-EB3FP1LR55"></script>
<script define:vars={{gTag}}> <script define:vars={{gTag: siteConfig?.googleAnalytics?.iv.gTag}}>
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);} function gtag(){dataLayer.push(arguments);}
gtag('js', new Date()); gtag('js', new Date());
@ -40,23 +80,74 @@ const gTag = config.GoogleAnalyticsGTag;
</head> </head>
<body> <body>
<slot /> <slot />
<div class="footer-container"> <div class="footer-container" data-data-squidex-token={siteEditToken}>
<div class="footer"> <div class="footer">
<div class="footer-header"> <div class="footer-header">
<p class="logo"><a class="daball-navbar-brand" href="https://daball.me">A <span class="pretty">pretty</span> <span class="cool">cool</span> website by <span class="david">David</span> <span class="allen">A.</span> <span class="ball">Ball</span>.</a></p> <Fragment set:html={interpolateString(getLocaleField(locale, site.siteBrand)!, renderContext)} />
</div> </div>
<div class="footer-content"> <div class="footer-content">
<p> <div>
&copy; Copyright 2024 <a href="https://daball.me">David A. Ball</a>. <a href="/">Dasher Supply</a> is not endorsed by nor affiliated with <a href="https://www.doordash.com/" target="_blank">DoorDash</a>. <Fragment set:html={renderMarkdown(getLocaleField(locale, site.copyright)!, renderContext)} />
</div>
<div>
<slot name="disclaimers" /> <slot name="disclaimers" />
</p> </div>
<p><a href="/">Home page</a>. | <a href="/about">About this site</a>. | <a href="https://gitea.daball.me/daball/dashersupply">Open source</a>.</p> <div>
<Fragment set:html={renderMarkdown(getLocaleField(locale, site.footerLinks)!, renderContext)} />
</div>
</div> </div>
</div> </div>
</div> </div>
{shouldEmbedSquidexSDK && (
<script define:vars={{ cssFixSquidex }} is:inline>
console.log('entered squidex css fix script');
$(document).ready(function () {
console.log('entered $(document).ready()');
console.log('searching for all data-squidex-token attributes to replace with squidex-token attributes');
$('[data-squidex-token]').each(function (i, el) {
$(el).attr('squidex-token', $(el).attr('data-squidex-token'));
console.log('patched data-squidex-token with squidex-token', el);
});
console.log('searching for #squidex-sdk');
var squidexSDK = $("#squidex-sdk");
if (squidexSDK.length > 0) {
console.log('found #squidex-sdk, checking for ready');
$(squidexSDK[0]).ready(function () {
console.log('#squidex-sdk is ready');
var WAIT_FOR = 100/*ms*/; //or, 0.5s
var STOP_AFTER = 60000/*ms*/; //or, 60s; call off the search
var counting = 0/*ms*/; //going from 0 to STOP_AFTER
console.log('searching for .squidex every ' + WAIT_FOR + 'ms');
var tester = setInterval(function () {
console.log('entered setInterval after ' + WAIT_FOR + 'ms');
var foundSquidex = $('.squidex').length > 0;
console.log('foundSquidex = ' + foundSquidex);
if (foundSquidex) {
console.log('found Squidex');
console.log('injecting css for squidEx fix: \"' + cssFixSquidex + '\"');
$('head').html($('head').html() + cssFixSquidex)
console.log('clearing setInterval')
clearInterval(tester);
}
else if ((counting += WAIT_FOR) >= STOP_AFTER) {
console.log('calling off the search, clearing setInterval')
clearInterval(tester);
}
}, WAIT_FOR);
});
}
});
</script>
<script id="squidex-sdk" src={`${config.squidexPublicUrl||config.squidexEnvironment}/scripts/embed-sdk.js`} is:inline></script>
)}
</body> </body>
</html> </html>
<style is:global> <style is:global>
img, svg { vertical-align: middle; }
*, :after, :before { box-sizing: border-box; }
.center {
text-align: center;
}
/* Uncomment for Firefox: * { /* Uncomment for Firefox: * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #4C1314 #23262D; scrollbar-color: #4C1314 #23262D;
@ -116,15 +207,16 @@ const gTag = config.GoogleAnalyticsGTag;
} }
.footer-container { .footer-container {
margin: auto; margin: auto;
padding: 1rem;
min-width: 800px; min-width: 800px;
max-width: calc(100% - 2rem); max-width: calc(100% - 2rem);
} }
.footer { .footer {
/* border: 1px solid #23262d; */ /* border: 1px solid #23262d; */
border: none; border: none;
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%)); background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(35, 38, 45, 0%));
border: 1px solid rgba(var(--accent-light), 25%); border: 1px solid rgba(var(--accent-light), 25%);
border-left: 0px;
border-right: 0px;
border-bottom: 0px; border-bottom: 0px;
padding: 0; padding: 0;
color: #fff; color: #fff;
@ -174,4 +266,47 @@ const gTag = config.GoogleAnalyticsGTag;
} }
a, a:link, a:visited, .logo a:hover, .logo a:active { text-decoration: none; color: #fff } a, a:link, a:visited, .logo a:hover, .logo a:active { text-decoration: none; color: #fff }
a:hover, a:active { text-decoration: underline; color: #fff } a:hover, a:active { text-decoration: underline; color: #fff }
</style> .callout p {
display: inline;
margin-bottom: 0rem;
}
.squidex-overlay-border {
transition: 700ms;
/*background: rgba(0, 119, 255, 0.25) !important;*/
background: linear-gradient(rgba(0, 119, 255, 0.4), rgba(0, 119, 255, 0.1)) !important;
padding: 1em !important;
border: 0.5em solid rgba(0, 119, 255, 0.75) !important;
border-radius: 8px !important;
}
.squidex-overlay-toolbar {
border-radius: 8px !important;
}
.squidex-overlay-border:hover {
border-radius: 8rem;
}
.squidex-overlay-schema {
background: linear-gradient(rgba(var(--accent-dark), 90%), rgba(var(--accent-dark), 33%)) !important;
border-top-left-radius: 5px !important;
border-bottom-left-radius: 5px !important;
border-bottom: 5px;
padding: 5rem;
height: unset !important;
}
.squidex-overlay-links {
background: linear-gradient(rgba(35, 38, 45, 90%), rgba(35, 38, 45, 33%)) !important;
background-color: unset !important;
border-top-right-radius: 5px !important;
height: unset !important;
border-bottom-right-radius: 5px !important;
}
.squidex-overlay, .squidex-overlay-schema, .squidex-overlay-links {
font-family: "Urbanist", system-ui, sans-serif !important;
font-size: 18pt !important;
}
.squidex-overlay-schema, .squidex-overlay-border:hover {
/*background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%)) !important;*/
}
.squidex-iframe, .squidex-iframe iframe {
border-radius: 0.5em !important;
}
</style>

View File

@ -0,0 +1,80 @@
/**
* Forked from https://github.com/opentable/accept-language-parser.
*/
const regex = /((([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;q=[0-1](\.[0-9]+)?)?)*/g;
const isString = (s: any) => typeof(s) === 'string';
export interface ParsedAcceptLanguage {
code: string,
script: any,
region: string,
quality: number,
}
export interface ParsedAcceptLanguageOptions {
loose?: boolean,
}
export const parse = (al: string) => {
let strings = (al || "").match(regex);
return strings!.map((m) => {
if(!m){
return;
}
let bits = m.split(';');
let ietf = bits[0].split('-');
let hasScript = ietf.length === 3;
return {
code: ietf[0],
script: hasScript ? ietf[1] : null,
region: hasScript ? ietf[2] : ietf[1],
quality: bits[1] ? parseFloat(bits[1].split('=')[1]) : 1.0
} as ParsedAcceptLanguage;
})
.filter((r) => r)
.sort((a, b) => b!.quality - a!.quality);
}
export const pick = (supportedLanguageOrLanguages: string|string[], acceptLanguage: string, options?: ParsedAcceptLanguageOptions) => {
options = options || { loose: false };
if (!supportedLanguageOrLanguages || !supportedLanguageOrLanguages.length || !acceptLanguage) {
return null;
}
let parsedLanguage = parse(acceptLanguage);
let supportedLanguages = isString(supportedLanguageOrLanguages) ? [supportedLanguageOrLanguages] : supportedLanguageOrLanguages;
let supported = supportedLanguages.map((support) => {
let bits = support.split('-');
let hasScript = bits.length === 3;
return {
code: bits[0],
script: hasScript ? bits[1] : null,
region: hasScript ? bits[2] : bits[1]
};
});
for (let i = 0; i < parsedLanguage.length; i++) {
const lang = parsedLanguage[i]!;
const langCode = lang.code.toLowerCase();
const langRegion = lang.region ? lang.region.toLowerCase() : lang.region;
const langScript = lang.script ? lang.script.toLowerCase() : lang.script;
for (let j = 0; j < supported.length; j++) {
const supportedCode = supported[j].code.toLowerCase();
const supportedScript = supported[j].script ? supported[j].script!.toLowerCase() : supported[j].script;
const supportedRegion = supported[j].region ? supported[j].region!.toLowerCase() : supported[j].region;
if (langCode === supportedCode &&
(options.loose || !langScript || langScript === supportedScript) &&
(options.loose || !langRegion || langRegion === supportedRegion)) {
return supportedLanguages[j];
}
}
}
return null;
}

24
src/lib/disclaimer.ts Normal file
View File

@ -0,0 +1,24 @@
// export interface DisclaimerDefinition {
// renderedText: string,
// marketplaceEditToken: string,
// };
// export class Disclaimers {
// private disclaimers: DisclaimerDefinition[] = [];
// public pushDisclaimer(disclaimerDefinition: DisclaimerDefinition) {
// // if (0 !== this.disclaimers.filter((searchDisclaimers) => {
// // if (searchDisclaimers.marketplaceEditToken === disclaimerDefinition.marketplaceEditToken) {
// // return true;
// // }
// // }).length) {
// this.disclaimers.push(disclaimerDefinition);
// // }
// }
// public getDisclaimers() {
// return this.disclaimers;
// }
// }
// export type PushDisclaimerCallback = (disclaimerDefinition: DisclaimerDefinition) => void;

16
src/lib/locales.ts Normal file
View File

@ -0,0 +1,16 @@
export const localeUrlTestPatterns = ['en-US', 'es-US', 'fr-CA'];
export const localeFromLang = (lang: string) => {
switch (lang) {
default: return 'en-US';
case 'es': return 'es-US';
case 'fr': return 'fr-CA';
}
};
export const overrideLocaleFromUrl = (url: string = '', orLang: string) => {
for (let t = 0; t < localeUrlTestPatterns.length; t++) {
if (url === localeUrlTestPatterns[t] || url.startsWith(`${localeUrlTestPatterns[t]}/`)) {
return localeUrlTestPatterns[t];
}
}
return orLang;
};

411
src/lib/page-from-models.ts Normal file
View File

@ -0,0 +1,411 @@
import type { Brand } from '../data/models/multis/Brand';
import type { Page } from '../data/models/multis/Page';
import type { ProductCategory } from '../data/models/multis/ProductCategory';
import type { Marketplace } from '../data/models/multis/Marketplace';
import type { Seller } from '../data/models/multis/Seller';
import type { Product } from '../data/models/multis/Product';
import type { QueryComponent } from '../data/models/components/QueryComponent';
export interface PageForBrandParams {
brand: Brand,
brandId: string,
homePageId: string,
};
export interface PageForProductCategoryParams {
productCategory: ProductCategory,
productCategoryId: string,
homePageId: string,
};
export interface PageForMarketplaceParams {
marketplace: Marketplace,
marketplaceId: string,
homePageId: string,
};
export interface PageForSellerParams {
seller: Seller,
sellerId: string,
homePageId: string,
};
export interface PageForProductParams {
product: Product,
productId: string,
homePageId: string,
};
/**
* genericPageForBrand() will generate a default Page object given
* a Brand interface.
*/
export const genericPageForBrand = ({ brand, brandId, homePageId }: PageForBrandParams ): Page => {
let componentsPerLanguage = [
{
schemaId: '',
schemaName: 'page-breadcrumbs',
},
{
schemaId: '',
schemaName: 'page-brand',
brand: [brandId],
},
{
schemaId: '',
schemaName: 'page-products-query',
jsonQuery: {
filter: {
path: 'data.brand.iv',
op: 'eq',
value: brandId
}
},
} as QueryComponent,
];
return {
title: brand.brandName,
slug: brand.slug,
content: {
"en-US": componentsPerLanguage,
"es-US": componentsPerLanguage,
"fr-CA": componentsPerLanguage,
},
seo: {
"en-US": {
schemaId: '',
schemaName: '',
keywords: brand.brandName['en-US'],
metaDescription: brand.shortDescription['en-US'],
metaImage: '',
metaSocial: [],
metaTitle: brand.brandName['en-US'],
},
"es-US": {
schemaId: '',
schemaName: '',
keywords: brand.brandName['es-US'],
metaDescription: brand.shortDescription['es-US'],
metaImage: '',
metaSocial: [],
metaTitle: brand.brandName['es-US'],
},
"fr-CA": {
schemaId: '',
schemaName: '',
keywords: brand.brandName['fr-CA'],
metaDescription: brand.shortDescription['fr-CA'],
metaImage: '',
metaSocial: [],
metaTitle: brand.brandName['fr-CA'],
},
},
parentPage: { iv: [homePageId] },
}
};
/**
* genericPageForProductCategory() will generate a default Page object given
* a ProductCategory interface.
*/
export const genericPageForProductCategory = ({ productCategory, productCategoryId, homePageId }: PageForProductCategoryParams ): Page => {
let componentsPerLanguage = [
{
schemaId: '',
schemaName: 'page-breadcrumbs',
},
{
schemaId: '',
schemaName: 'page-product-category',
productCategory: [productCategory],
},
{
schemaId: '',
schemaName: 'page-product-categories-query',
jsonQuery: {
filter: {
path: "data.parentCategory.iv",
op: "eq",
value: productCategoryId,
}
},
} as QueryComponent,
{
schemaId: '',
schemaName: 'page-products-query',
jsonQuery: {
filter: {
path: "data.categories.iv",
op: "eq",
value: productCategoryId,
}
},
} as QueryComponent,
];
return {
title: productCategory.categoryName,
slug: productCategory.slug,
content: {
"en-US": componentsPerLanguage,
"es-US": componentsPerLanguage,
"fr-CA": componentsPerLanguage,
},
seo: {
"en-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: productCategory.description['en-US'],
metaImage: '',
metaSocial: [],
metaTitle: productCategory.categoryName['en-US'],
},
"es-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: productCategory.description['es-US'],
metaImage: '',
metaSocial: [],
metaTitle: productCategory.categoryName['es-US'],
},
"fr-CA": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: productCategory.description['fr-CA'],
metaImage: '',
metaSocial: [],
metaTitle: productCategory.categoryName['fr-CA'],
},
},
parentPage: { iv: [homePageId] },
}
};
/**
* genericPageForMarketplace() will generate a default Page object given
* a Marketplace interface.
*/
export const genericPageForMarketplace = ({ marketplace, marketplaceId, homePageId }: PageForMarketplaceParams ): Page => {
let componentsPerLanguage = [
{
schemaId: '',
schemaName: 'page-breadcrumbs',
},
{
schemaId: '',
schemaName: 'page-marketplace',
productCategory: [marketplace],
},
{
schemaId: '',
schemaName: 'page-sellers-query',
// jsonQuery: JSON.stringify({
// filter: {
// path: "data.parentCategory.iv",
// op: "eq",
// value: productCategoryId,
// }
// }),
} as QueryComponent,
{
schemaId: '',
schemaName: 'page-products-query',
// jsonQuery: JSON.stringify({
// filter: {
// path: "data.parentCategory.iv",
// op: "eq",
// value: productCategoryId,
// }
// }),
} as QueryComponent,
];
return {
title: marketplace.marketplaceName,
slug: marketplace.slug,
content: {
"en-US": componentsPerLanguage,
"es-US": componentsPerLanguage,
"fr-CA": componentsPerLanguage,
},
seo: {
"en-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: marketplace.shortDescription['en-US'],
metaImage: '',
metaSocial: [],
metaTitle: marketplace.marketplaceName['en-US'],
},
"es-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: marketplace.shortDescription['es-US'],
metaImage: '',
metaSocial: [],
metaTitle: marketplace.marketplaceName['es-US'],
},
"fr-CA": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: marketplace.shortDescription['fr-CA'],
metaImage: '',
metaSocial: [],
metaTitle: marketplace.marketplaceName['fr-CA'],
},
},
parentPage: { iv: [homePageId] },
}
};
/*
* genericPageForSeller() will generate a default Page object given
* a Seller interface.
*/
export const genericPageForSeller = ({ seller, sellerId, homePageId }: PageForSellerParams ): Page => {
let componentsPerLanguage = [
{
schemaId: '',
schemaName: 'page-breadcrumbs',
},
{
schemaId: '',
schemaName: 'page-seller',
productCategory: [seller],
},
{
schemaId: '',
schemaName: 'page-marketplaces-query',
// jsonQuery: JSON.stringify({
// filter: {
// path: "data.parentCategory.iv",
// op: "eq",
// value: productCategoryId,
// }
// }),
} as QueryComponent,
{
schemaId: '',
schemaName: 'page-products-query',
// jsonQuery: JSON.stringify({
// filter: {
// path: "data.parentCategory.iv",
// op: "eq",
// value: productCategoryId,
// }
// }),
} as QueryComponent,
];
return {
title: seller.sellerName,
slug: seller.slug,
content: {
"en-US": componentsPerLanguage,
"es-US": componentsPerLanguage,
"fr-CA": componentsPerLanguage,
},
seo: {
"en-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: seller.sellerBio['en-US'],
metaImage: '',
metaSocial: [],
metaTitle: seller.sellerName['en-US'],
},
"es-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: seller.sellerBio['es-US'],
metaImage: '',
metaSocial: [],
metaTitle: seller.sellerName['es-US'],
},
"fr-CA": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: seller.sellerBio['fr-CA'],
metaImage: '',
metaSocial: [],
metaTitle: seller.sellerName['fr-CA'],
},
},
parentPage: { iv: [homePageId] },
}
};
/*
* genericPageForSeller() will generate a default Page object given
* a Seller interface.
*/
export const genericPageForProduct = ({ product, productId, homePageId }: PageForProductParams ): Page => {
let componentsPerLanguage = [
{
schemaId: '',
schemaName: 'page-breadcrumbs',
},
{
schemaId: '',
schemaName: 'page-product',
productCategory: [product],
},
{
schemaId: '',
schemaName: 'page-marketplaces-query',
// jsonQuery: JSON.stringify({
// filter: {
// path: "data.parentCategory.iv",
// op: "eq",
// value: productCategoryId,
// }
// }),
} as QueryComponent
];
return {
title: product.productName,
slug: product.slug,
content: {
"en-US": componentsPerLanguage,
"es-US": componentsPerLanguage,
"fr-CA": componentsPerLanguage,
},
seo: {
"en-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: product.description['en-US'],
metaImage: '',
metaSocial: [],
metaTitle: product.productName['en-US'],
},
"es-US": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: product.description['es-US'],
metaImage: '',
metaSocial: [],
metaTitle: product.productName['es-US'],
},
"fr-CA": {
schemaId: '',
schemaName: '',
keywords: '',
metaDescription: product.description['fr-CA'],
metaImage: '',
metaSocial: [],
metaTitle: product.productName['fr-CA'],
},
},
parentPage: { iv: [homePageId] },
}
};

75
src/lib/rendering.ts Normal file
View File

@ -0,0 +1,75 @@
import markdownIt from "markdown-it";
import markdownItAttrs from "markdown-it-attrs";
import vm from 'node:vm';
export const md = markdownIt().use(markdownItAttrs, {
});
function escapeRegExp(re: string) {
return re.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function makeInterpolationRegex(delimStart: string = '${', delimEnd: string = '}') {
delimStart = escapeRegExp(delimStart);
delimEnd = escapeRegExp(delimEnd);
return new RegExp(['(', delimStart, '[^', delimEnd, ']+', delimEnd, ')'].join(''), 'm')
}
function makeInterpolationEvalRegex(delimStart: string = '${', delimEnd: string = '}') {
delimStart = escapeRegExp(delimStart);
delimEnd = escapeRegExp(delimEnd);
return new RegExp([delimStart, '([^', delimEnd, ']+)', delimEnd].join(''), 'g')
}
export const interpolateString = (template: string, templateContext?: any) => {
const interpolationRegex = makeInterpolationRegex(); ///(\${[^}]+})/g;
// const interpolationEvalRegex = /\${([^}]+)}/g;
const interpolationEvalRegex = makeInterpolationEvalRegex();
const vmContext = vm.createContext(templateContext);
let matches = template.match(interpolationRegex);
while (matches && matches.length > 0) {
const jsCode = matches[0].replace(interpolationEvalRegex, '$1');
const interpolatedOutput = vm.runInContext(jsCode, vmContext, { timeout: 100, breakOnSigint: true, displayErrors: true });
//interpolate and iterate
template = template.replace(interpolationRegex, interpolatedOutput);
matches = template.match(interpolationRegex);
}
return template;
}
export const renderHtml = (template: string, templateContext?: any) => {
const interpolationRegex = /(\${[^}]+})/g;
const interpolationEvalRegex = /\${([^}]+)}/g;
const vmContext = vm.createContext(templateContext);
let matches = template.match(interpolationRegex);
while (matches && matches.length > 0) {
const jsCode = matches[0].replace(interpolationEvalRegex, '$1');
const interpolatedOutput = vm.runInContext(jsCode, vmContext, { timeout: 100, breakOnSigint: true, displayErrors: true });
//interpolate and iterate
template = template.replace(interpolationRegex, interpolatedOutput);
matches = template.match(interpolationRegex);
}
return template;
}
const renderCodeblock = (lang: string, template: string, templateContext?: any) => {
const startDelim = '```' + lang + '\n';
const endDelim = '\n```';
const interpolationRegex = makeInterpolationRegex(startDelim, endDelim);
const interpolationDeepMdRegex = makeInterpolationEvalRegex(startDelim, endDelim);
let matches = template.match(interpolationRegex);
while (matches && matches.length > 0) {
const cbCode = matches[0].replace(interpolationDeepMdRegex, '$1');
template = template.replace(interpolationRegex, cbCode);
template = interpolateString(template, templateContext);
matches = template.match(interpolationRegex)
}
return template;
}
export const renderMarkdown = (template: string, templateContext?: any) => {
// render code blocks inside of the Markdown
template = renderCodeblock('markdown', template, templateContext);
template = md.render(interpolateString(template, templateContext));
return template;
}

88
src/lib/strapi.ts Normal file
View File

@ -0,0 +1,88 @@
import { config } from "../config";
import * as http from 'node:http';
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import fs, { mkdir } from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface Props {
endpoint: string;
query?: Record<string, string>;
wrappedByKey?: string;
wrappedByList?: boolean;
}
/**
* Fetches data from the Strapi API
* @param endpoint - The endpoint to fetch from
* @param query - The query parameters to add to the url
* @param wrappedByKey - The key to unwrap the response from
* @param wrappedByList - If the response is a list, unwrap it
* @returns
*/
export async function fetchApi<T>({
endpoint,
query,
wrappedByKey,
wrappedByList,
}: Props): Promise<T> {
if (endpoint.startsWith('/')) {
endpoint = endpoint.slice(1);
}
const url = new URL(`${import.meta.env.STRAPI_URL}/api/${endpoint}`);
if (query) {
Object.entries(query).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const res = await fetch(url.toString(), {
headers: {
"Authorization": `bearer ${config.strapiApiToken}`,
},
});
let data = await res.json();
if (wrappedByKey) {
data = data[wrappedByKey];
}
if (wrappedByList) {
data = data[0];
}
return data as T;
}
export function downloadMedia(subdir: string, filename: string, url: string) {
// console.log('downloadMedia hit with', subdir, filename, url);
const imageUrl = `${config.strapiUrl}${url}`;
const subdirPath = path.join(__dirname, '..', '..', 'public', 'media', subdir);
if (!existsSync(subdirPath)) {
mkdirSync(subdirPath, { recursive: true });
}
const imagePath = path.join(subdirPath, filename);
const imageFile = createWriteStream(imagePath);
http.get(
// {
// headers: {
// "Authorization": `bearer ${config.strapiApiToken}`,
// },
// href: imageUrl,
// },
imageUrl,
response => {
response.pipe(imageFile);
imageFile.on('finish', () => {
imageFile.close();
//console.log(`Image downloaded as ${imagePath}`);
});
}).on('error', err => {
imageFile.close();
unlinkSync(imagePath);
console.error(`Error downloading image: ${err.message}`);
});
}

144
src/old-data/api-models.ts Normal file
View File

@ -0,0 +1,144 @@
import { type BlocksContent } from '@strapi/blocks-react-renderer';
export interface MetaPagination {
page?: number;
pageSize?: number;
pageCount?: number;
total?: number;
}
export interface Meta extends MetaPagination {
}
export interface Response<T> {
data: T;
meta: Meta;
}
export interface Single<T> {
id: number;
attributes: T;
}
export interface HasId {
id: number;
}
// export enum RichTextBlockType {
// 'paragraph',
// 'list',
// 'text',
// }
// export enum RichTextBlockFormat {
// 'unordered',
// 'ordered',
// }
// export interface RichTextBlock {
// type: RichTextBlockType;
// format?: RichTextBlockFormat;
// text: string;
// }
// export interface RichTextBlocks {
// type: RichTextBlockType;
// children: RichTextBlock[];
// }
export type RichTextBlocks = BlocksContent;
export interface CreatedModified {
createdAt: Date;
updatedAt: Date;
}
export interface Site extends CreatedModified {
siteName: string;
siteUrl: string;
metaDescription: string;
categoriesCallout: RichTextBlocks;
brandsCallout: RichTextBlocks;
marketplaceCallout: RichTextBlocks;
}
export interface AmazonPAAPIConfig extends CreatedModified {
accessKey: string;
secretKey: string;
partnerType: string;
partnerTag: string;
marketplace: string;
service: string;
host: string;
region: string;
}
export interface GoogleAdsense extends CreatedModified {
adsTxt: string;
}
export interface GoogleAnalytics extends CreatedModified {
gTag: string;
}
export interface Media extends CreatedModified {
name: string;
alternativeText: string;
caption: string;
width: number;
height: number;
format: null|any;
hash: string;
ext: string;
mime: string;
size: number;
url: string;
previewUrl: string|null;
provider: string;
provider_metadata: null|any;
}
export interface Marketplace extends CreatedModified {
name: string;
slug: string;
shortDescription: RichTextBlocks;
description: RichTextBlocks;
disclaimer: RichTextBlocks;
logoImage: Response<Single<Media>>;
// products: Response<Single<Product>[]>;
}
export interface Brand extends CreatedModified {
name: string;
slug: string;
shortDescription: RichTextBlocks;
description: RichTextBlocks;
logoImage: Response<Single<Media>>;
products: Response<Single<Product>[]>;
}
export interface Category extends CreatedModified {
name: string;
slug: string;
categoryImage: Response<Single<Media>>;
description: RichTextBlocks;
products: Response<Single<Product>[]>;
parentCategories: Response<Single<Category>[]>;
childCategories: Response<Single<Category>[]>;
}
export interface Tag extends CreatedModified {
slug: string;
}
export interface Product extends CreatedModified {
name: string;
title: string;
slug: string;
brand: Response<Single<Brand>>;
callout: RichTextBlocks;
description: RichTextBlocks;
categories: Response<Single<Category>[]>;
tags: Response<Single<Tag>[]>;
components: any[];
}

View File

@ -33,7 +33,7 @@ deer-related collisions. Continue to drive safely and rest assured more deer wil
Don't let deer-related accidents hold you back from delivering safely and efficiently. Get the Bell Don't let deer-related accidents hold you back from delivering safely and efficiently. Get the Bell
Automotive-22-1-01000-8 Black Deer Warning Unit today and enjoy peace of mind behind the wheel! Automotive-22-1-01000-8 Black Deer Warning Unit today and enjoy peace of mind behind the wheel!
`.trim(), `.trim(),
amazonProductId: 'B000CC4O58', ASIN: 'B000CC4O58',
amazonLink: 'https://www.amazon.com/Bell-Automotive-22-1-01000-8-Black-Warning/dp/B000CC4O58?crid=38OWGTE3CGGTR&dib=eyJ2IjoiMSJ9.2hsaXGo5j3z_PO0DdSqY2dh0ERcaf1BLgOVM5jRO6_8YsWEDMVv1R9XYUsALRJPK7hWsylfyJucQttI1MRPR7YrBuBhDAJVzXue3BLjSRwHS3tzLix_0BMleTroTTDMtOyzuGpJGlkfq4ayiBl1HtOKN1HmSfUWnseEzDdmdYfiPGena7b4L1qIAz5AnMd0zdy0-YuddgQiHjM3Ha57GzK0w-HSTpHUeAIDrFQFEhcbCBnTRYPtrDEqvFlVd2E2-rmbQgqrb9YDaZo-zTNBGuHt2liqz_4hN6fwXQ9-BfPM.Atf0vKmCupgwmiW_lZj49dmQMkvMPIjQsfx3PqYlqIc&dib_tag=se&keywords=deer+alert&qid=1721016740&sprefix=deer+aler%2Caps%2C92&sr=8-17&linkCode=ll1&tag=dashersupply-20&linkId=623763eba9c22a3e4d3618007f6b13ae&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/Bell-Automotive-22-1-01000-8-Black-Warning/dp/B000CC4O58?crid=38OWGTE3CGGTR&dib=eyJ2IjoiMSJ9.2hsaXGo5j3z_PO0DdSqY2dh0ERcaf1BLgOVM5jRO6_8YsWEDMVv1R9XYUsALRJPK7hWsylfyJucQttI1MRPR7YrBuBhDAJVzXue3BLjSRwHS3tzLix_0BMleTroTTDMtOyzuGpJGlkfq4ayiBl1HtOKN1HmSfUWnseEzDdmdYfiPGena7b4L1qIAz5AnMd0zdy0-YuddgQiHjM3Ha57GzK0w-HSTpHUeAIDrFQFEhcbCBnTRYPtrDEqvFlVd2E2-rmbQgqrb9YDaZo-zTNBGuHt2liqz_4hN6fwXQ9-BfPM.Atf0vKmCupgwmiW_lZj49dmQMkvMPIjQsfx3PqYlqIc&dib_tag=se&keywords=deer+alert&qid=1721016740&sprefix=deer+aler%2Caps%2C92&sr=8-17&linkCode=ll1&tag=dashersupply-20&linkId=623763eba9c22a3e4d3618007f6b13ae&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails: { amazonProductDetails: {
"title": "Bell Automotive-22-1-01000-8 Bell Deer Warning Unit, Black, PR", "title": "Bell Automotive-22-1-01000-8 Bell Deer Warning Unit, Black, PR",
@ -108,7 +108,7 @@ deer-related collisions. Continue to drive safely and rest assured more deer wil
Don't let deer-related accidents hold you back from delivering safely and efficiently. Get the Bell Don't let deer-related accidents hold you back from delivering safely and efficiently. Get the Bell
Automotive-22-1-01001-8 Chrome Deer Warning Unit today and enjoy peace of mind behind the wheel! Automotive-22-1-01001-8 Chrome Deer Warning Unit today and enjoy peace of mind behind the wheel!
`.trim(), `.trim(),
amazonProductId: 'B000IG5PVU', ASIN: 'B000IG5PVU',
amazonLink: 'https://www.amazon.com/Bell-Automotive-22-1-01001-8-Chrome-Warning/dp/B000IG5PVU?crid=292GBL5WF6BRS&dib=eyJ2IjoiMSJ9.bJ1O7X9g1iSFNRAATSwis8O-HyUVPobiDj9SFPCzloSL6FVx3V5odzkPH8plk5R-Y9JoWUFRYq8qwR-QjxRb6-9pCZYAnCaKv1I_GY6CMUP5F07CSo-gqPd2yMfr1YjiFHMB_CHwA7vs_eUSFT9jkEenaQtEp1_3yDHrjxTpSWUMiJGZwfzksmhStFE82p031qxt2LQxT9YpabM7XMguPc2ZFv4-PIrU55VbXxzqgf9CyVj3S_QSujOZmGuqBqgQQhVXzfL0TwQocB1xsTMVmiFepdozsBMuUFleATwNsyM.CCOSpzyeJTssD33Vns57cdSlHsXfWXPe2k1_lKfGT1M&dib_tag=se&keywords=bell+automotive+deer+alert&qid=1721018357&sprefix=bell+automotive+deer+alert%2Caps%2C69&sr=8-3&linkCode=ll1&tag=dashersupply-20&linkId=a26ad59d6d5109c7d46d43d8b1af4cad&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/Bell-Automotive-22-1-01001-8-Chrome-Warning/dp/B000IG5PVU?crid=292GBL5WF6BRS&dib=eyJ2IjoiMSJ9.bJ1O7X9g1iSFNRAATSwis8O-HyUVPobiDj9SFPCzloSL6FVx3V5odzkPH8plk5R-Y9JoWUFRYq8qwR-QjxRb6-9pCZYAnCaKv1I_GY6CMUP5F07CSo-gqPd2yMfr1YjiFHMB_CHwA7vs_eUSFT9jkEenaQtEp1_3yDHrjxTpSWUMiJGZwfzksmhStFE82p031qxt2LQxT9YpabM7XMguPc2ZFv4-PIrU55VbXxzqgf9CyVj3S_QSujOZmGuqBqgQQhVXzfL0TwQocB1xsTMVmiFepdozsBMuUFleATwNsyM.CCOSpzyeJTssD33Vns57cdSlHsXfWXPe2k1_lKfGT1M&dib_tag=se&keywords=bell+automotive+deer+alert&qid=1721018357&sprefix=bell+automotive+deer+alert%2Caps%2C69&sr=8-3&linkCode=ll1&tag=dashersupply-20&linkId=a26ad59d6d5109c7d46d43d8b1af4cad&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails: { amazonProductDetails: {
"title": "Bell Automotive 22-1-01001-8 Chrome Deer Warning", "title": "Bell Automotive 22-1-01001-8 Chrome Deer Warning",

View File

@ -1,8 +1,10 @@
import type { SearchItemsResponse } from "amazon-pa-api5-node-ts";
/* ********** Signed Request ********** */ /* ********** Signed Request ********** */
https://webservices.amazon.com/!YW16LTEuMDtjb20uYW1hem9uLnBhYXBpNS52MS5Qcm9kdWN0QWR2ZXJ0aXNpbmdBUEl2MS5TZWFyY2hJdGVtczt7CiAgICAiS2V5d29yZHMiOiAiZmxhc2hsaWdodCIsCiAgICAiUmVzb3VyY2VzIjogWwogICAgICAgICJCcm93c2VOb2RlSW5mby5Ccm93c2VOb2RlcyIsCiAgICAgICAgIkJyb3dzZU5vZGVJbmZvLkJyb3dzZU5vZGVzLkFuY2VzdG9yIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uQnJvd3NlTm9kZXMuU2FsZXNSYW5rIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uV2Vic2l0ZVNhbGVzUmFuayIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5Db3VudCIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5TdGFyUmF0aW5nIiwKICAgICAgICAiSW1hZ2VzLlByaW1hcnkuU21hbGwiLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5NZWRpdW0iLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5MYXJnZSIsCiAgICAgICAgIkltYWdlcy5QcmltYXJ5LkhpZ2hSZXMiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuU21hbGwiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuTWVkaXVtIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkxhcmdlIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkhpZ2hSZXMiLAogICAgICAgICJJdGVtSW5mby5CeUxpbmVJbmZvIiwKICAgICAgICAiSXRlbUluZm8uQ29udGVudEluZm8iLAogICAgICAgICJJdGVtSW5mby5Db250ZW50UmF0aW5nIiwKICAgICAgICAiSXRlbUluZm8uQ2xhc3NpZmljYXRpb25zIiwKICAgICAgICAiSXRlbUluZm8uRXh0ZXJuYWxJZHMiLAogICAgICAgICJJdGVtSW5mby5GZWF0dXJlcyIsCiAgICAgICAgIkl0ZW1JbmZvLk1hbnVmYWN0dXJlSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlByb2R1Y3RJbmZvIiwKICAgICAgICAiSXRlbUluZm8uVGVjaG5pY2FsSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlRpdGxlIiwKICAgICAgICAiSXRlbUluZm8uVHJhZGVJbkluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQ29uZGl0aW9uIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5Db25kaXRpb25Ob3RlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5TdWJDb25kaXRpb24iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLklzQW1hem9uRnVsZmlsbGVkIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0ZyZWVTaGlwcGluZ0VsaWdpYmxlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc1ByaW1lRWxpZ2libGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLlNoaXBwaW5nQ2hhcmdlcyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Jc0J1eUJveFdpbm5lciIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Mb3lhbHR5UG9pbnRzLlBvaW50cyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5NZXJjaGFudEluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJpY2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVFeGNsdXNpdmUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVQYW50cnkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvbW90aW9ucyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5TYXZpbmdCYXNpcyIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuSGlnaGVzdFByaWNlIiwKICAgICAgICAiT2ZmZXJzLlN1bW1hcmllcy5Mb3dlc3RQcmljZSIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuT2ZmZXJDb3VudCIsCiAgICAgICAgIlBhcmVudEFTSU4iLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQmFzZVByaWNlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbiIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uQ29uZGl0aW9uTm90ZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uU3ViQ29uZGl0aW9uIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0FtYXpvbkZ1bGZpbGxlZCIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNGcmVlU2hpcHBpbmdFbGlnaWJsZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNQcmltZUVsaWdpYmxlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5TaGlwcGluZ0NoYXJnZXMiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuTWVyY2hhbnRJbmZvIiwKICAgICAgICAiU2VhcmNoUmVmaW5lbWVudHMiCiAgICBdLAogICAgIkJyYW5kIjogIkNPQVNUIiwKICAgICJQYXJ0bmVyVGFnIjogImRhc2hlcnN1cHBseS0yMCIsCiAgICAiUGFydG5lclR5cGUiOiAiQXNzb2NpYXRlcyIsCiAgICAiTWFya2V0cGxhY2UiOiAid3d3LmFtYXpvbi5jb20iCn0= export const signedRequest = `https://webservices.amazon.com/!YW16LTEuMDtjb20uYW1hem9uLnBhYXBpNS52MS5Qcm9kdWN0QWR2ZXJ0aXNpbmdBUEl2MS5TZWFyY2hJdGVtczt7CiAgICAiS2V5d29yZHMiOiAiZmxhc2hsaWdodCIsCiAgICAiUmVzb3VyY2VzIjogWwogICAgICAgICJCcm93c2VOb2RlSW5mby5Ccm93c2VOb2RlcyIsCiAgICAgICAgIkJyb3dzZU5vZGVJbmZvLkJyb3dzZU5vZGVzLkFuY2VzdG9yIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uQnJvd3NlTm9kZXMuU2FsZXNSYW5rIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uV2Vic2l0ZVNhbGVzUmFuayIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5Db3VudCIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5TdGFyUmF0aW5nIiwKICAgICAgICAiSW1hZ2VzLlByaW1hcnkuU21hbGwiLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5NZWRpdW0iLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5MYXJnZSIsCiAgICAgICAgIkltYWdlcy5QcmltYXJ5LkhpZ2hSZXMiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuU21hbGwiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuTWVkaXVtIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkxhcmdlIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkhpZ2hSZXMiLAogICAgICAgICJJdGVtSW5mby5CeUxpbmVJbmZvIiwKICAgICAgICAiSXRlbUluZm8uQ29udGVudEluZm8iLAogICAgICAgICJJdGVtSW5mby5Db250ZW50UmF0aW5nIiwKICAgICAgICAiSXRlbUluZm8uQ2xhc3NpZmljYXRpb25zIiwKICAgICAgICAiSXRlbUluZm8uRXh0ZXJuYWxJZHMiLAogICAgICAgICJJdGVtSW5mby5GZWF0dXJlcyIsCiAgICAgICAgIkl0ZW1JbmZvLk1hbnVmYWN0dXJlSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlByb2R1Y3RJbmZvIiwKICAgICAgICAiSXRlbUluZm8uVGVjaG5pY2FsSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlRpdGxlIiwKICAgICAgICAiSXRlbUluZm8uVHJhZGVJbkluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQ29uZGl0aW9uIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5Db25kaXRpb25Ob3RlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5TdWJDb25kaXRpb24iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLklzQW1hem9uRnVsZmlsbGVkIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0ZyZWVTaGlwcGluZ0VsaWdpYmxlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc1ByaW1lRWxpZ2libGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLlNoaXBwaW5nQ2hhcmdlcyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Jc0J1eUJveFdpbm5lciIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Mb3lhbHR5UG9pbnRzLlBvaW50cyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5NZXJjaGFudEluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJpY2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVFeGNsdXNpdmUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVQYW50cnkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvbW90aW9ucyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5TYXZpbmdCYXNpcyIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuSGlnaGVzdFByaWNlIiwKICAgICAgICAiT2ZmZXJzLlN1bW1hcmllcy5Mb3dlc3RQcmljZSIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuT2ZmZXJDb3VudCIsCiAgICAgICAgIlBhcmVudEFTSU4iLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQmFzZVByaWNlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbiIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uQ29uZGl0aW9uTm90ZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uU3ViQ29uZGl0aW9uIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0FtYXpvbkZ1bGZpbGxlZCIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNGcmVlU2hpcHBpbmdFbGlnaWJsZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNQcmltZUVsaWdpYmxlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5TaGlwcGluZ0NoYXJnZXMiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuTWVyY2hhbnRJbmZvIiwKICAgICAgICAiU2VhcmNoUmVmaW5lbWVudHMiCiAgICBdLAogICAgIkJyYW5kIjogIkNPQVNUIiwKICAgICJQYXJ0bmVyVGFnIjogImRhc2hlcnN1cHBseS0yMCIsCiAgICAiUGFydG5lclR5cGUiOiAiQXNzb2NpYXRlcyIsCiAgICAiTWFya2V0cGxhY2UiOiAid3d3LmFtYXpvbi5jb20iCn0=`;
/* ********** Payload ********** */ /* ********** Payload ********** */
{ export const payload = {
"Keywords": "flashlight", "Keywords": "flashlight",
"Resources": [ "Resources": [
"BrowseNodeInfo.BrowseNodes", "BrowseNodeInfo.BrowseNodes",
@ -73,10 +75,10 @@ https://webservices.amazon.com/!YW16LTEuMDtjb20uYW1hem9uLnBhYXBpNS52MS5Qcm9kdWN0
"PartnerType": "Associates", "PartnerType": "Associates",
"Marketplace": "www.amazon.com", "Marketplace": "www.amazon.com",
"Operation": "SearchItems" "Operation": "SearchItems"
} };
/* ********** Response ********** */ /* ********** Response ********** */
{ export const response: SearchItemsResponse = {
"SearchResult": { "SearchResult": {
"Items": [ "Items": [
{ {
@ -4304,4 +4306,4 @@ https://webservices.amazon.com/!YW16LTEuMDtjb20uYW1hem9uLnBhYXBpNS52MS5Qcm9kdWN0
"SearchURL": "https://www.amazon.com/s?k=flashlight&rh=p_n_availability%3A-1%2Cp_lbr_brands_browse-bin%3ACOAST&tag=dashersupply-20&linkCode=osi", "SearchURL": "https://www.amazon.com/s?k=flashlight&rh=p_n_availability%3A-1%2Cp_lbr_brands_browse-bin%3ACOAST&tag=dashersupply-20&linkCode=osi",
"TotalResultCount": 181 "TotalResultCount": 181
} }
} };

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,41 @@
import { type Product } from '../products/product'; import { type Product } from '../products/product';
import { getCategoryIdForSlug } from '../categories'; import { getCategoryIdForSlug } from '../categories';
import slugify from 'slugify';
export const BRAND_STORE_SLUG = 'coast'; export const BRAND_STORE_SLUG = 'coast';
import jsonSearchResults from './coast-query-response.json';
import { type SearchItemsResponse } from 'amazon-pa-api5-node-ts';
let parsedResults = jsonSearchResults!.SearchResult.Items.map((value) => {
let product: Product = {
categoryId: getCategoryIdForSlug('safety-equipment')!,
name: value.ItemInfo!.Title!.DisplayValue!,
title: value.ItemInfo!.Title!.DisplayValue!,
slug: slugify(value.ItemInfo!.Title!.DisplayValue!, {
replacement: '-',
remove: undefined,
lower: true,
strict: true,
locale: 'en',
trim: true,
}),
tags: ['flashlight', 'safety'],
amazonLink: value.DetailPageURL!,
amazonProductDetails: {
featureBullets: value.ItemInfo!.Features!.DisplayValues!,
imageUrls: [
value.Images!.Primary!.Large!.URL!,
...value.Images!.Variants!.map((image) => image.Large.URL)
],
price: value.Offers!.Listings![0].Price!.Amount!,
},
ASIN: value.ASIN!,
brandStoreSlug: BRAND_STORE_SLUG,
callout: `House numbers can be tricky to locate late in the evening.`
};
return product;
})
export const CoastStoreProducts: Product[] = [ export const CoastStoreProducts: Product[] = [
{ {
slug: 'coast-polysteel-600-led-flashlight', slug: 'coast-polysteel-600-led-flashlight',
@ -38,7 +71,7 @@ Whether you're navigating through dark alleys, driveways, signaling for help, or
the COAST Polysteel 600 LED Flashlight has got your back. Its durable design and rechargeable options make it a reliable companion for the COAST Polysteel 600 LED Flashlight has got your back. Its durable design and rechargeable options make it a reliable companion for
delivery drivers like you. delivery drivers like you.
`.trim(), `.trim(),
amazonProductId: 'B00SJRDIN2', ASIN: 'B00SJRDIN2',
amazonLink: 'https://www.amazon.com/Polysteel-600-Waterproof-Flashlight-Stainless/dp/B00SJRDIN2?crid=29BV6TGKIV7U4&dib=eyJ2IjoiMSJ9.z_qqGdUikpKLO62rjeDuDoQDki7kToAVTM2kBLri4vs25y739Ll_nFVMziV7A5ZnYGQQYNujGdg5igViybnULLsVCa_T6qCk9HUVk7GuD30Jp0FrydoVV9zm-m-E9Zhi7vGbjJdDxUmYXypCL_GaGT6O6K4gf2P94QITVfbbBrjNT74VL9ZdRfs9ucPUSjkoTNLCMXcAXf4fXnJqniXk4PyFks_YYcZ9K8IDN4Fp-puEBc5lhdIp2hY4ugsmMD2v9zYNTvaTD1EaAnXVA_UXIrGwSTdg3Q2cWoqWF6sw6mo.z0JvreFTZ58D14a2IuwCSDybpR9x_CTUBSRrNlP9aZs&dib_tag=se&keywords=coast+flash+light&qid=1720695258&s=sporting-goods&sprefix=coast+flash+light%2Csporting%2C83&sr=1-27&linkCode=ll1&tag=dashersupply-20&linkId=9cfd6086ba43fac649f6884f72c7c844&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/Polysteel-600-Waterproof-Flashlight-Stainless/dp/B00SJRDIN2?crid=29BV6TGKIV7U4&dib=eyJ2IjoiMSJ9.z_qqGdUikpKLO62rjeDuDoQDki7kToAVTM2kBLri4vs25y739Ll_nFVMziV7A5ZnYGQQYNujGdg5igViybnULLsVCa_T6qCk9HUVk7GuD30Jp0FrydoVV9zm-m-E9Zhi7vGbjJdDxUmYXypCL_GaGT6O6K4gf2P94QITVfbbBrjNT74VL9ZdRfs9ucPUSjkoTNLCMXcAXf4fXnJqniXk4PyFks_YYcZ9K8IDN4Fp-puEBc5lhdIp2hY4ugsmMD2v9zYNTvaTD1EaAnXVA_UXIrGwSTdg3Q2cWoqWF6sw6mo.z0JvreFTZ58D14a2IuwCSDybpR9x_CTUBSRrNlP9aZs&dib_tag=se&keywords=coast+flash+light&qid=1720695258&s=sporting-goods&sprefix=coast+flash+light%2Csporting%2C83&sr=1-27&linkCode=ll1&tag=dashersupply-20&linkId=9cfd6086ba43fac649f6884f72c7c844&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails: { amazonProductDetails: {
"title": "COAST POLYSTEEL 600 1000 Lumen LED Flashlight with Pure Beam Twist Focus, Stainless-Steel Core, Crushproof, Black", "title": "COAST POLYSTEEL 600 1000 Lumen LED Flashlight with Pure Beam Twist Focus, Stainless-Steel Core, Crushproof, Black",
@ -86,5 +119,6 @@ delivery drivers like you.
} }
] ]
} }
} },
...parsedResults
]; ];

View File

@ -0,0 +1,575 @@
import type { GetItemsResponse } from "amazon-pa-api5-node-ts";
/* ********** Signed Request ********** */
export const signedRequest = `https://webservices.amazon.com/!YW16LTEuMDtjb20uYW1hem9uLnBhYXBpNS52MS5Qcm9kdWN0QWR2ZXJ0aXNpbmdBUEl2MS5HZXRJdGVtczt7CiAgICAiSXRlbUlkcyI6IFsKICAgICAgICAiQjAwMVNHNzZNVSIKICAgIF0sCiAgICAiUmVzb3VyY2VzIjogWwogICAgICAgICJCcm93c2VOb2RlSW5mby5Ccm93c2VOb2RlcyIsCiAgICAgICAgIkJyb3dzZU5vZGVJbmZvLkJyb3dzZU5vZGVzLkFuY2VzdG9yIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uQnJvd3NlTm9kZXMuU2FsZXNSYW5rIiwKICAgICAgICAiQnJvd3NlTm9kZUluZm8uV2Vic2l0ZVNhbGVzUmFuayIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5Db3VudCIsCiAgICAgICAgIkN1c3RvbWVyUmV2aWV3cy5TdGFyUmF0aW5nIiwKICAgICAgICAiSW1hZ2VzLlByaW1hcnkuU21hbGwiLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5NZWRpdW0iLAogICAgICAgICJJbWFnZXMuUHJpbWFyeS5MYXJnZSIsCiAgICAgICAgIkltYWdlcy5QcmltYXJ5LkhpZ2hSZXMiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuU21hbGwiLAogICAgICAgICJJbWFnZXMuVmFyaWFudHMuTWVkaXVtIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkxhcmdlIiwKICAgICAgICAiSW1hZ2VzLlZhcmlhbnRzLkhpZ2hSZXMiLAogICAgICAgICJJdGVtSW5mby5CeUxpbmVJbmZvIiwKICAgICAgICAiSXRlbUluZm8uQ29udGVudEluZm8iLAogICAgICAgICJJdGVtSW5mby5Db250ZW50UmF0aW5nIiwKICAgICAgICAiSXRlbUluZm8uQ2xhc3NpZmljYXRpb25zIiwKICAgICAgICAiSXRlbUluZm8uRXh0ZXJuYWxJZHMiLAogICAgICAgICJJdGVtSW5mby5GZWF0dXJlcyIsCiAgICAgICAgIkl0ZW1JbmZvLk1hbnVmYWN0dXJlSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlByb2R1Y3RJbmZvIiwKICAgICAgICAiSXRlbUluZm8uVGVjaG5pY2FsSW5mbyIsCiAgICAgICAgIkl0ZW1JbmZvLlRpdGxlIiwKICAgICAgICAiSXRlbUluZm8uVHJhZGVJbkluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuQ29uZGl0aW9uIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5Db25kaXRpb25Ob3RlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbi5TdWJDb25kaXRpb24iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLklzQW1hem9uRnVsZmlsbGVkIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0ZyZWVTaGlwcGluZ0VsaWdpYmxlIiwKICAgICAgICAiT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc1ByaW1lRWxpZ2libGUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuRGVsaXZlcnlJbmZvLlNoaXBwaW5nQ2hhcmdlcyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Jc0J1eUJveFdpbm5lciIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5Mb3lhbHR5UG9pbnRzLlBvaW50cyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5NZXJjaGFudEluZm8iLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJpY2UiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVFeGNsdXNpdmUiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvZ3JhbUVsaWdpYmlsaXR5LklzUHJpbWVQYW50cnkiLAogICAgICAgICJPZmZlcnMuTGlzdGluZ3MuUHJvbW90aW9ucyIsCiAgICAgICAgIk9mZmVycy5MaXN0aW5ncy5TYXZpbmdCYXNpcyIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuSGlnaGVzdFByaWNlIiwKICAgICAgICAiT2ZmZXJzLlN1bW1hcmllcy5Mb3dlc3RQcmljZSIsCiAgICAgICAgIk9mZmVycy5TdW1tYXJpZXMuT2ZmZXJDb3VudCIsCiAgICAgICAgIlBhcmVudEFTSU4iLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1heE9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1lc3NhZ2UiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5Lk1pbk9yZGVyUXVhbnRpdHkiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQXZhaWxhYmlsaXR5LlR5cGUiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuQmFzZVByaWNlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkNvbmRpdGlvbiIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uQ29uZGl0aW9uTm90ZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5Db25kaXRpb24uU3ViQ29uZGl0aW9uIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5Jc0FtYXpvbkZ1bGZpbGxlZCIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNGcmVlU2hpcHBpbmdFbGlnaWJsZSIsCiAgICAgICAgIlJlbnRhbE9mZmVycy5MaXN0aW5ncy5EZWxpdmVyeUluZm8uSXNQcmltZUVsaWdpYmxlIiwKICAgICAgICAiUmVudGFsT2ZmZXJzLkxpc3RpbmdzLkRlbGl2ZXJ5SW5mby5TaGlwcGluZ0NoYXJnZXMiLAogICAgICAgICJSZW50YWxPZmZlcnMuTGlzdGluZ3MuTWVyY2hhbnRJbmZvIgogICAgXSwKICAgICJQYXJ0bmVyVGFnIjogImRhc2hlcnN1cHBseS0yMCIsCiAgICAiUGFydG5lclR5cGUiOiAiQXNzb2NpYXRlcyIsCiAgICAiTWFya2V0cGxhY2UiOiAid3d3LmFtYXpvbi5jb20iCn0=`;
/* ********** Payload ********** */
export const payload = {
"ItemIds": [
"B001SG76MU"
],
"Resources": [
"BrowseNodeInfo.BrowseNodes",
"BrowseNodeInfo.BrowseNodes.Ancestor",
"BrowseNodeInfo.BrowseNodes.SalesRank",
"BrowseNodeInfo.WebsiteSalesRank",
"CustomerReviews.Count",
"CustomerReviews.StarRating",
"Images.Primary.Small",
"Images.Primary.Medium",
"Images.Primary.Large",
"Images.Primary.HighRes",
"Images.Variants.Small",
"Images.Variants.Medium",
"Images.Variants.Large",
"Images.Variants.HighRes",
"ItemInfo.ByLineInfo",
"ItemInfo.ContentInfo",
"ItemInfo.ContentRating",
"ItemInfo.Classifications",
"ItemInfo.ExternalIds",
"ItemInfo.Features",
"ItemInfo.ManufactureInfo",
"ItemInfo.ProductInfo",
"ItemInfo.TechnicalInfo",
"ItemInfo.Title",
"ItemInfo.TradeInInfo",
"Offers.Listings.Availability.MaxOrderQuantity",
"Offers.Listings.Availability.Message",
"Offers.Listings.Availability.MinOrderQuantity",
"Offers.Listings.Availability.Type",
"Offers.Listings.Condition",
"Offers.Listings.Condition.ConditionNote",
"Offers.Listings.Condition.SubCondition",
"Offers.Listings.DeliveryInfo.IsAmazonFulfilled",
"Offers.Listings.DeliveryInfo.IsFreeShippingEligible",
"Offers.Listings.DeliveryInfo.IsPrimeEligible",
"Offers.Listings.DeliveryInfo.ShippingCharges",
"Offers.Listings.IsBuyBoxWinner",
"Offers.Listings.LoyaltyPoints.Points",
"Offers.Listings.MerchantInfo",
"Offers.Listings.Price",
"Offers.Listings.ProgramEligibility.IsPrimeExclusive",
"Offers.Listings.ProgramEligibility.IsPrimePantry",
"Offers.Listings.Promotions",
"Offers.Listings.SavingBasis",
"Offers.Summaries.HighestPrice",
"Offers.Summaries.LowestPrice",
"Offers.Summaries.OfferCount",
"ParentASIN",
"RentalOffers.Listings.Availability.MaxOrderQuantity",
"RentalOffers.Listings.Availability.Message",
"RentalOffers.Listings.Availability.MinOrderQuantity",
"RentalOffers.Listings.Availability.Type",
"RentalOffers.Listings.BasePrice",
"RentalOffers.Listings.Condition",
"RentalOffers.Listings.Condition.ConditionNote",
"RentalOffers.Listings.Condition.SubCondition",
"RentalOffers.Listings.DeliveryInfo.IsAmazonFulfilled",
"RentalOffers.Listings.DeliveryInfo.IsFreeShippingEligible",
"RentalOffers.Listings.DeliveryInfo.IsPrimeEligible",
"RentalOffers.Listings.DeliveryInfo.ShippingCharges",
"RentalOffers.Listings.MerchantInfo"
],
"PartnerTag": "dashersupply-20",
"PartnerType": "Associates",
"Marketplace": "www.amazon.com",
"Operation": "GetItems"
};
/* ********** Response ********** */
export const response: GetItemsResponse = {
"ItemsResult": {
"Items": [
{
"ASIN": "B001SG76MU",
"BrowseNodeInfo": {
"BrowseNodes": [
{
"Ancestor": {
"Ancestor": {
"Ancestor": {
"Ancestor": {
"Ancestor": {
"ContextFreeName": "Sports & Outdoors",
"DisplayName": "Sports & Outdoors",
"Id": "3375251"
},
"ContextFreeName": "Sports & Outdoors",
"DisplayName": "Categories",
"Id": "3375301"
},
"ContextFreeName": "Outdoor Recreation",
"DisplayName": "Outdoor Recreation",
"Id": "706814011"
},
"ContextFreeName": "Camping & Hiking Equipment",
"DisplayName": "Camping & Hiking",
"Id": "3400371"
},
"ContextFreeName": "Camping Safety & Survival Equipment",
"DisplayName": "Safety & Survival",
"Id": "3401081"
},
"ContextFreeName": "Camping First Aid Kits",
"DisplayName": "First Aid Kits",
"Id": "3401101",
"IsRoot": false,
"SalesRank": 7
},
{
"Ancestor": {
"Ancestor": {
"Ancestor": {
"Ancestor": {
"Ancestor": {
"Ancestor": {
"ContextFreeName": "Health & Household",
"DisplayName": "Health & Household",
"Id": "3760901"
},
"ContextFreeName": "Arborist Merchandising Root",
"DisplayName": "Arborist Merchandising Root",
"Id": "119756821011"
},
"ContextFreeName": "Self Service",
"DisplayName": "Self Service",
"Id": "2334097011"
},
"ContextFreeName": "Special Features Stores",
"DisplayName": "Special Features Stores",
"Id": "2334159011"
},
"ContextFreeName": "ab47f18a-1a7a-4dbe-b89a-001bfaccbe8b_0",
"DisplayName": "ab47f18a-1a7a-4dbe-b89a-001bfaccbe8b_0",
"Id": "119756826011"
},
"ContextFreeName": "ab47f18a-1a7a-4dbe-b89a-001bfaccbe8b_901",
"DisplayName": "ab47f18a-1a7a-4dbe-b89a-001bfaccbe8b_901",
"Id": "119757260011"
},
"ContextFreeName": "All First Aid",
"DisplayName": "All First Aid",
"Id": "24423742011",
"IsRoot": false
}
],
"WebsiteSalesRank": {
"SalesRank": 2662
}
},
"DetailPageURL": "https://www.amazon.com/dp/B001SG76MU?tag=dashersupply-20&linkCode=ogi&th=1&psc=1",
"Images": {
"Primary": {
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/413sfboF0mL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/413sfboF0mL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/413sfboF0mL._SL75_.jpg",
"Width": 75
}
},
"Variants": [
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/51PHxfhu09L._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/51PHxfhu09L._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/51PHxfhu09L._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/41DrWanEf2L._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/41DrWanEf2L._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/41DrWanEf2L._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/51vA72L9CkL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/51vA72L9CkL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/51vA72L9CkL._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/41UoP-S+XuL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/41UoP-S+XuL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/41UoP-S+XuL._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/51XP7Me5+IL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/51XP7Me5+IL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/51XP7Me5+IL._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/51+kXOErgOL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/51+kXOErgOL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/51+kXOErgOL._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/41rLINs+hGL._SL500_.jpg",
"Width": 499
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/41rLINs+hGL._SL160_.jpg",
"Width": 159
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/41rLINs+hGL._SL75_.jpg",
"Width": 74
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/31ZSaHsFmTL._SL500_.jpg",
"Width": 499
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/31ZSaHsFmTL._SL160_.jpg",
"Width": 159
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/31ZSaHsFmTL._SL75_.jpg",
"Width": 74
}
},
{
"Large": {
"Height": 500,
"URL": "https://m.media-amazon.com/images/I/31-tEgCUzPL._SL500_.jpg",
"Width": 499
},
"Medium": {
"Height": 160,
"URL": "https://m.media-amazon.com/images/I/31-tEgCUzPL._SL160_.jpg",
"Width": 159
},
"Small": {
"Height": 75,
"URL": "https://m.media-amazon.com/images/I/31-tEgCUzPL._SL75_.jpg",
"Width": 74
}
},
{
"Large": {
"Height": 375,
"URL": "https://m.media-amazon.com/images/I/41Rw5lXzcWL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 120,
"URL": "https://m.media-amazon.com/images/I/41Rw5lXzcWL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 56,
"URL": "https://m.media-amazon.com/images/I/41Rw5lXzcWL._SL75_.jpg",
"Width": 75
}
},
{
"Large": {
"Height": 375,
"URL": "https://m.media-amazon.com/images/I/41DHqh2WnqL._SL500_.jpg",
"Width": 500
},
"Medium": {
"Height": 120,
"URL": "https://m.media-amazon.com/images/I/41DHqh2WnqL._SL160_.jpg",
"Width": 160
},
"Small": {
"Height": 56,
"URL": "https://m.media-amazon.com/images/I/41DHqh2WnqL._SL75_.jpg",
"Width": 75
}
}
]
},
"ItemInfo": {
"ByLineInfo": {
"Brand": {
"DisplayValue": "First Aid Only",
"Label": "Brand",
"Locale": "en_US"
},
"Manufacturer": {
"DisplayValue": "Acme United",
"Label": "Manufacturer",
"Locale": "en_US"
}
},
"Classifications": {
"Binding": {
"DisplayValue": "Misc.",
"Label": "Binding",
"Locale": "en_US"
},
"ProductGroup": {
"DisplayValue": "BISS",
"Label": "ProductGroup",
"Locale": "en_US"
}
},
"ContentInfo": {
"PublicationDate": {
"DisplayValue": "2018-01-01T00:00:01Z",
"Label": "PublicationDate",
"Locale": "en_US"
}
},
"ExternalIds": {
"EANs": {
"DisplayValues": [
"0738743060608"
],
"Label": "EAN",
"Locale": "en_US"
},
"UPCs": {
"DisplayValues": [
"738743060608"
],
"Label": "UPC",
"Locale": "en_US"
}
},
"Features": {
"DisplayValues": [
"Comprehensive Emergency Kit: Includes adhesive fabric and plastic bandages, antibiotic ointments, BZK antiseptic towelettes, burn cream packets, gauze roll and pads, gloves, scissors, tweezers, and other multi-use first aid items",
"Convenient Packaging: An ideal worksite or office first aid kit, it features a durable plastic case complete with an easy-to-carry handle for transporting first aid supplies in an emergency",
"Easy Access: This convenient and versatile home, office, and jobsite first aid kit features individual compartments that make accessing first aid supplies quick and easy",
"Compact Size: Small enough to fit nicely into a backpack, vehicle compartment, or desk drawer, this travel-size first aid kit helps you stay prepared for potential emergencies when at home, in the office, or while on the go",
"Personal and Professional First Aid Solutions: First Aid Only offers a full line of first aid kits, cabinets, and stations, Emergency Response Care, individual first aid products, Spill Clean Up kits, CPR care and more"
],
"Label": "Features",
"Locale": "en_US"
},
"ManufactureInfo": {
"ItemPartNumber": {
"DisplayValue": "6060",
"Label": "PartNumber",
"Locale": "en_US"
},
"Model": {
"DisplayValue": "6060",
"Label": "Model",
"Locale": "en_US"
},
"Warranty": {
"DisplayValue": "Manufacturer",
"Label": "Warranty",
"Locale": "en_US"
}
},
"ProductInfo": {
"Color": {
"DisplayValue": "White",
"Label": "Color",
"Locale": "en_US"
},
"IsAdultProduct": {
"DisplayValue": false,
"Label": "IsAdultProduct",
"Locale": "en_US"
},
"ItemDimensions": {
"Height": {
"DisplayValue": 8,
"Label": "Height",
"Locale": "en_US",
"Unit": "inches"
},
"Length": {
"DisplayValue": 5,
"Label": "Length",
"Locale": "en_US",
"Unit": "inches"
},
"Weight": {
"DisplayValue": 0.8,
"Label": "Weight",
"Locale": "en_US",
"Unit": "Pounds"
},
"Width": {
"DisplayValue": 3,
"Label": "Width",
"Locale": "en_US",
"Unit": "inches"
}
},
"ReleaseDate": {
"DisplayValue": "2018-01-01T00:00:01Z",
"Label": "ReleaseDate",
"Locale": "en_US"
},
"Size": {
"DisplayValue": "57 Piece",
"Label": "Size",
"Locale": "en_US"
},
"UnitCount": {
"DisplayValue": 1,
"Label": "NumberOfItems",
"Locale": "en_US"
}
},
"Title": {
"DisplayValue": "First Aid Only 6060 10-Person Emergency First Aid Kit for Office, Home, and Worksites, 57 Pieces",
"Label": "Title",
"Locale": "en_US"
}
},
"Offers": {
"Listings": [
{
"Availability": {
"Message": "In Stock",
"MinOrderQuantity": 1,
"Type": "Now"
},
"Condition": {
"SubCondition": {
"Value": "New"
},
"Value": "New"
},
"DeliveryInfo": {
"IsAmazonFulfilled": true,
"IsFreeShippingEligible": true,
"IsPrimeEligible": true
},
"Id": "EeHLy5pUHgzNO7xwG3nGTlyXMscRGCy2TkNM4pN2zPDPyzqCHeou9sueJCz7yOWjlAzQzm28WmVretpxwqlrl1IKry0MFoLOO%2BE6pQSH0spbDH6jSs1bzJVbX8F4W%2BB2zzpLvoo9DOIAzEdoHHYzHA%3D%3D",
"IsBuyBoxWinner": true,
"MerchantInfo": {
"FeedbackCount": 385,
"FeedbackRating": 4.68,
"Id": "ATVPDKIKX0DER",
"Name": "Amazon.com"
},
"Price": {
"Amount": 20.99,
"Currency": "USD",
"DisplayAmount": "$20.99"
},
"ProgramEligibility": {
"IsPrimeExclusive": false,
"IsPrimePantry": false
},
"Promotions": [
{
"Amount": 20.99,
"Currency": "USD",
"DiscountPercent": 0,
"DisplayAmount": "$20.99 (0%)",
"Type": "SNS"
}
],
"ViolatesMAP": false
}
],
"Summaries": [
{
"Condition": {
"Value": "New"
},
"HighestPrice": {
"Amount": 41.16,
"Currency": "USD",
"DisplayAmount": "$41.16"
},
"LowestPrice": {
"Amount": 20.99,
"Currency": "USD",
"DisplayAmount": "$20.99"
},
"OfferCount": 22
}
]
}
}
]
}
}

View File

@ -6,7 +6,7 @@ export const BRAND_STORE_SLUG = 'first-aid-only';
export const FirstAidOnlyStoreProducts: Product[] = [ export const FirstAidOnlyStoreProducts: Product[] = [
{ {
amazonLink: 'https://www.amazon.com/First-Aid-Only-Weatherproof-Plastic/dp/B001SG76MU?crid=17746AVZ2R4TK&dib=eyJ2IjoiMSJ9.nehq12VwBTB17Vyx1YODXq7JYQbnOM8xv6AZRadSceLpsk33o-ES3M7UnJMkq0usrVmB1uKgdw9rxtPf7wcS1fHI_DhXIkjp7ujnBf0xvt-SjW3Xw__yU6NvYnSUmSfQzcqj49ZMu893KSypCAIPiLZ0gHo9HbRPicFsuJVBOCv5aOQoBqlLRymArai_8k9lUwtCxAfhfiDjUGk6K3s_S6IFWUP88Ff8mbyU5lkVRtbE4dRTCp-wNjM6HpxqZPSZ0A3_-PPl75PlgjsmUXIkxArreEPatqaHwyJ13X-DCQU.CWOEqmjYxSJ7yRXLCgtz9iGOSGJD53MSoPw6jzAWx7Q&dib_tag=se&keywords=first+aid+kit&qid=1720746463&sprefix=first+aid+kit%2Caps%2C95&sr=8-3-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9hdGY&psc=1&linkCode=ll1&tag=dashersupply-20&linkId=385f21e08641ef9ce7ad55aebe2d30cf&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/First-Aid-Only-Weatherproof-Plastic/dp/B001SG76MU?crid=17746AVZ2R4TK&dib=eyJ2IjoiMSJ9.nehq12VwBTB17Vyx1YODXq7JYQbnOM8xv6AZRadSceLpsk33o-ES3M7UnJMkq0usrVmB1uKgdw9rxtPf7wcS1fHI_DhXIkjp7ujnBf0xvt-SjW3Xw__yU6NvYnSUmSfQzcqj49ZMu893KSypCAIPiLZ0gHo9HbRPicFsuJVBOCv5aOQoBqlLRymArai_8k9lUwtCxAfhfiDjUGk6K3s_S6IFWUP88Ff8mbyU5lkVRtbE4dRTCp-wNjM6HpxqZPSZ0A3_-PPl75PlgjsmUXIkxArreEPatqaHwyJ13X-DCQU.CWOEqmjYxSJ7yRXLCgtz9iGOSGJD53MSoPw6jzAWx7Q&dib_tag=se&keywords=first+aid+kit&qid=1720746463&sprefix=first+aid+kit%2Caps%2C95&sr=8-3-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9hdGY&psc=1&linkCode=ll1&tag=dashersupply-20&linkId=385f21e08641ef9ce7ad55aebe2d30cf&language=en_US&ref_=as_li_ss_tl',
amazonProductId: 'B001SG76MU', ASIN: 'B001SG76MU',
slug: 'first-aid-only-57-pc-first-aid-kit', slug: 'first-aid-only-57-pc-first-aid-kit',
categoryId: getCategoryIdForSlug('safety-equipment')!, categoryId: getCategoryIdForSlug('safety-equipment')!,
name: "57-pc First Aid Kit (small)", name: "57-pc First Aid Kit (small)",

View File

@ -29,7 +29,7 @@ harsh chemicals behind, making it a true joy to work with.
Experience the difference for yourself. With its powerful foaming action and guaranteed streak-free results, you'll be able to drive Experience the difference for yourself. With its powerful foaming action and guaranteed streak-free results, you'll be able to drive
with confidence and clarity - no matter what the road throws your way. with confidence and clarity - no matter what the road throws your way.
`.trim(), `.trim(),
amazonProductId: 'B0007OWD2M', ASIN: 'B0007OWD2M',
amazonLink: 'https://www.amazon.com/Invisible-Glass-91166-6PK-Premium-Cleaner/dp/B0007OWD2M?hvadid=80607997944702&hvnetw=o&hvqmt=e&hvbmt=be&hvdev=c&hvlocint=&hvlocphy=&hvtargid=pla-4584207585873841&th=1&linkCode=ll1&tag=dashersupply-20&linkId=a81b62e34ab769132cbe8076316b448d&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/Invisible-Glass-91166-6PK-Premium-Cleaner/dp/B0007OWD2M?hvadid=80607997944702&hvnetw=o&hvqmt=e&hvbmt=be&hvdev=c&hvlocint=&hvlocphy=&hvtargid=pla-4584207585873841&th=1&linkCode=ll1&tag=dashersupply-20&linkId=a81b62e34ab769132cbe8076316b448d&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails: { amazonProductDetails: {
"title": "Invisible Glass 91164 19-Ounce Cleaner for Auto and Home for a Streak-Free Shine, Deep Cleaning Foaming Action, Safe for Tinted and Non-Tinted Windows, Ammonia Free Foam Glass Cleaner", "title": "Invisible Glass 91164 19-Ounce Cleaner for Auto and Home for a Streak-Free Shine, Deep Cleaning Foaming Action, Safe for Tinted and Non-Tinted Windows, Ammonia Free Foam Glass Cleaner",

View File

@ -42,7 +42,7 @@ choose from a simulated rattle snake sound that takes advantage of nature's best
Don't let deer-related accidents hold you back from delivering packages and efficiently. With the nVISION Trailblazer Don't let deer-related accidents hold you back from delivering packages and efficiently. With the nVISION Trailblazer
Deer Alert, the deer will hear you coming. Deer Alert, the deer will hear you coming.
`.trim(), `.trim(),
amazonProductId: 'B0000DYV3N', ASIN: 'B0000DYV3N',
amazonLink: 'https://www.amazon.com/Hopkins-27512VA-nVISION-Trailblazer-Electronic/dp/B0000DYV3N?crid=1OO1GL5ERA30E&dib=eyJ2IjoiMSJ9.zKSyNMASsuqR2JLuPICupB5N0gr3mV9rCY5mDLq0nteE_I7uA99eMpyrFMlp9Rmg8y29jHsu5CXS6D6rVQbf_9X5S1rixss014Om-mY44aaW9aDxw7c2k11jxYHDo9ta6vfjAUMLS_TJ-HKmB4ens05KFPYASmgh7OMturk8CsZFebU5Wl9u3RJ3msToyu4a2iozCQzToauDe_sEWZcsK3k_oPGAwotTRsG5musYpYlTlxxBgxwE3Sii0Z4T-uqDd-BNGp3DkSfu2ZtF6gn7XK1rip8COHws2H8D1C3DxqY.9gjHTeHUVufso-AO7nuU4Zys_CClKElR_HM7Y0WBmMM&dib_tag=se&keywords=trailblazer+electronic+deer+alert&qid=1721017663&sprefix=trailblazer+electronic+deer+alert%2Caps%2C69&sr=8-1&linkCode=ll1&tag=dashersupply-20&linkId=7023f7183f50617ff8e739b6eb4a427a&language=en_US&ref_=as_li_ss_tl', amazonLink: 'https://www.amazon.com/Hopkins-27512VA-nVISION-Trailblazer-Electronic/dp/B0000DYV3N?crid=1OO1GL5ERA30E&dib=eyJ2IjoiMSJ9.zKSyNMASsuqR2JLuPICupB5N0gr3mV9rCY5mDLq0nteE_I7uA99eMpyrFMlp9Rmg8y29jHsu5CXS6D6rVQbf_9X5S1rixss014Om-mY44aaW9aDxw7c2k11jxYHDo9ta6vfjAUMLS_TJ-HKmB4ens05KFPYASmgh7OMturk8CsZFebU5Wl9u3RJ3msToyu4a2iozCQzToauDe_sEWZcsK3k_oPGAwotTRsG5musYpYlTlxxBgxwE3Sii0Z4T-uqDd-BNGp3DkSfu2ZtF6gn7XK1rip8COHws2H8D1C3DxqY.9gjHTeHUVufso-AO7nuU4Zys_CClKElR_HM7Y0WBmMM&dib_tag=se&keywords=trailblazer+electronic+deer+alert&qid=1721017663&sprefix=trailblazer+electronic+deer+alert%2Caps%2C69&sr=8-1&linkCode=ll1&tag=dashersupply-20&linkId=7023f7183f50617ff8e739b6eb4a427a&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails:{ amazonProductDetails:{
"title": "nVISION Hopkins 27512VA Trailblazer Electronic Deer Alert", "title": "nVISION Hopkins 27512VA Trailblazer Electronic Deer Alert",

View File

@ -30,7 +30,7 @@ all-purpose caddy is designed to keep your essentials organized and within reach
**Get Organized:** With its 8 rounded sections and durable design, this caddy is perfect for transporting frequently used sports drink bottles or even coffee cups. This Rubbermaid Commercial Deluxe Carry Caddy has got you covered. **Get Organized:** With its 8 rounded sections and durable design, this caddy is perfect for transporting frequently used sports drink bottles or even coffee cups. This Rubbermaid Commercial Deluxe Carry Caddy has got you covered.
`.trim(), `.trim(),
amazonProductId: 'B00006ICOT', ASIN: 'B00006ICOT',
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=dashersupply-20&linkId=1a29425189155a3bbe240c193bd1589e&language=en_US&ref_=as_li_ss_tl', 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=dashersupply-20&linkId=1a29425189155a3bbe240c193bd1589e&language=en_US&ref_=as_li_ss_tl',
amazonProductDetails: { amazonProductDetails: {
"title": "Rubbermaid Commercial Products Deluxe Carry Caddy for Take-Out Coffee/Soft Drinks, Postmates/Uber Eats/Food Delivery, Cleaning Products, Sports/Water Bottles, Black", "title": "Rubbermaid Commercial Products Deluxe Carry Caddy for Take-Out Coffee/Soft Drinks, Postmates/Uber Eats/Food Delivery, Cleaning Products, Sports/Water Bottles, Black",

View File

@ -33,7 +33,7 @@ clarity and confidence in a variety of environments.
The Crossfire HD binoculars bring HD optics, rugged performance and high end form-factor. Add in the included GlassPak binocular harness for quick optic deployment in the field and superior protection and comfort. The Crossfire HD truly is a rare find. The Crossfire HD binoculars bring HD optics, rugged performance and high end form-factor. Add in the included GlassPak binocular harness for quick optic deployment in the field and superior protection and comfort. The Crossfire HD truly is a rare find.
`.trim(), `.trim(),
amazonProductId: 'B07V3LB5DN', ASIN: 'B07V3LB5DN',
amazonProductDetails: { amazonProductDetails: {
"title": "Vortex Optics Crossfire HD 10x42 Binoculars - HD Optical System, Tripod Adaptable, Rubber Armor, Waterproof, Fogproof, Shockproof, Included GlassPak - Unlimited, Unconditional Warranty", "title": "Vortex Optics Crossfire HD 10x42 Binoculars - HD Optical System, Tripod Adaptable, Rubber Armor, Waterproof, Fogproof, Shockproof, Included GlassPak - Unlimited, Unconditional Warranty",
"description": "The Crossfire HD binoculars bring HD optics, rugged performance and high end form-factor. Add in the included GlassPak binocular harness for quick optic deployment in the field and superior protection and comfort - The Crossfire HD truly is a rare find.", "description": "The Crossfire HD binoculars bring HD optics, rugged performance and high end form-factor. Add in the included GlassPak binocular harness for quick optic deployment in the field and superior protection and comfort - The Crossfire HD truly is a rare find.",

172
src/old-data/fetch-site.ts Normal file
View File

@ -0,0 +1,172 @@
import { config } from "../config";
import { fetchApi, downloadMedia } from '../lib/strapi';
import { type AmazonPAAPIConfig, type Brand, type Category, type GoogleAdsense, type GoogleAnalytics, type HasId, type Media, type Marketplace, type Product, type Response, type Single, type Site, type Tag } from './api-models';
const siteResponse = (await fetchApi<Response<Single<Site>>>({
endpoint: 'site',
query: {
'populate': '*',
}
})).data;
export const site: Site = {
...siteResponse.attributes,
};
const amazonPAAPIConfigResponse = (await fetchApi<Response<Single<AmazonPAAPIConfig>>>({
endpoint: 'amazon-pa-api-config',
query: {
'populate': '*',
}
})).data;
export const amazonPAAPIConfig: AmazonPAAPIConfig = {
...amazonPAAPIConfigResponse.attributes,
}
const googleAdSenseResponse = (await fetchApi<Response<Single<GoogleAdsense>>>({
endpoint: 'google-ad-sense',
query: {
'populate': '*',
}
})).data;
export const googleAdSense: GoogleAdsense = {
...googleAdSenseResponse.attributes,
}
const googleAnalyticsResponse = (await fetchApi<Response<Single<GoogleAnalytics>>>({
endpoint: 'google-analytics',
query: {
'populate': '*',
}
})).data;
export const googleAnalytics: GoogleAnalytics = {
...googleAnalyticsResponse.attributes,
}
const brandsResponse = (await fetchApi<Response<Single<Brand>[]>>({
endpoint: 'brands',
query: {
'populate': '*',
}
})).data;
export let brands: Brand[]&HasId[] = brandsResponse.map(value => {
return {
id: value.id,
...value.attributes,
};
}) // sort by name ascending
.sort((left, right) => left.name.localeCompare(right.name))
;
const marketplacesResponse = (await fetchApi<Response<Single<Marketplace>[]>>({
endpoint: 'marketplaces',
query: {
'populate': '*',
// 'populate[components][populate]': '*',
}
})).data;
export const marketplaces: Marketplace[]&HasId[] = marketplacesResponse.map(value => {
return {
id: value.id,
...value.attributes,
};
}) // sort by name ascending
.sort((left, right) => left.name.localeCompare(right.name))
;
const categoriesResponse = (await fetchApi<Response<Single<Category>[]>>({
endpoint: 'categories',
query: {
'populate': '*',
// 'populate[0]': 'parentCategories',
// 'populate[1]': 'childCategories',
// 'populate[2]': 'products',
// 'populate[parentCategories][populate][0]': 'parentCategories',
// 'populate[parentCategories[0]][populate][parentCategories[0]]': '*',
// 'populate[parentCategories[0]][populate][parentCategories[0]][populate][parentCategories[0]]': '*',
}
})).data;
export const categories: Category[]&HasId[] = categoriesResponse.map(value => {
return {
id: value.id,
...value.attributes,
};
}) // sort by name ascending
.sort((left, right) => left.name.localeCompare(right.name))
;
const tagsResponse = (await fetchApi<Response<Single<Tag>[]>>({
endpoint: 'tags',
query: {
'populate': '*',
}
})).data;
export const tags: Tag[]&HasId[] = tagsResponse.map(value => {
return {
id: value.id,
...value.attributes,
};
}) // sort by name ascending
.sort((left, right) => left.slug.localeCompare(right.slug))
;
const productsResponse = (await fetchApi<Response<Single<Product>[]>>({
endpoint: 'products',
query: {
'populate': '*',
// 'populate[components][populate]': '*',
}
})).data;
export const products: Product[]&HasId[] = productsResponse.map(value => {
return {
id: value.id,
...value.attributes,
};
});
// download brand images
for (let i = 0; i < brands.length; i++) {
let brand = brands[i];
if (brand && brand.logoImage && brand.logoImage.data) {
let logoImageUrl = brand.logoImage.data.attributes;
downloadMedia('brands', `${logoImageUrl.hash}${logoImageUrl.ext}`, logoImageUrl.url);
brands[i].logoImage.data.attributes.url = `/media/brands/${logoImageUrl.hash}${logoImageUrl.ext}`;
}
}
for (let i = 0; i < marketplaces.length; i++) {
let marketplace = marketplaces[i];
if (marketplace && marketplace.logoImage && marketplace.logoImage.data) {
let logoImageUrl = marketplace.logoImage.data.attributes;
downloadMedia('marketplaces', `${logoImageUrl.hash}${logoImageUrl.ext}`, logoImageUrl.url);
marketplaces[i].logoImage.data.attributes.url = `/media/marketplaces/${logoImageUrl.hash}${logoImageUrl.ext}`;
}
}
for (let i = 0; i < categories.length; i++) {
let category = categories[i];
if (category && category.categoryImage && category.categoryImage.data) {
let categoryImageUrl = category.categoryImage.data.attributes;
downloadMedia('categories', `${categoryImageUrl.hash}${categoryImageUrl.ext}`, categoryImageUrl.url);
categories[i].categoryImage.data.attributes.url = `/media/categories/${categoryImageUrl.hash}${categoryImageUrl.ext}`;
}
}
// console.log('site', site);
// console.log('amazonPAAPIConfig', amazonPAAPIConfig);
// console.log('googleAdSense', googleAdSense);
// console.log('googleAnalytics', googleAnalytics);
// console.log('brands', brands);
// console.log('categories', categories);
// console.log('marketplaces', marketplaces);
// console.log('tags', tags);
// console.log('products', products);
// console.log(products[0].description[0].children);

View File

@ -1,6 +1,14 @@
import { type ProductAttribute } from "./product-attribute"; import { type ProductAttribute } from "./product-attribute";
export interface ProductDetails { export interface AmazonProductDetails {
/**
* Amazon product ID for the product.
*/
ASIN?: string;
/**
* Amazon link for the product.
*/
amazonLink?: string;
title?: string; title?: string;
price?: number; price?: number;
// listPrice?: number; // listPrice?: number;
@ -10,4 +18,4 @@ export interface ProductDetails {
reviewCount?: number; reviewCount?: number;
imageUrls?: string[]; imageUrls?: string[];
attributes?: ProductAttribute[]; attributes?: ProductAttribute[];
}; };

View File

@ -193,8 +193,155 @@ export function getProductsForCategoryId(categoryId: number) {
import { config } from "../../config"; import { config } from "../../config";
import * as ProductAdvertisingAPIv1 from 'amazon-pa-api5-node-ts'; import * as ProductAdvertisingAPIv1 from 'amazon-pa-api5-node-ts';
import { SearchItemsResourceValues } from 'amazon-pa-api5-node-ts/dist/src/model/SearchItemsResource';
let amazonClient = ProductAdvertisingAPIv1.ApiClient.instance; let amazonClient = ProductAdvertisingAPIv1.ApiClient.instance;
amazonClient.accessKey = config.AmazonProductAdvertisingAPIAccessKey; amazonClient.accessKey = config.AmazonProductAdvertisingAPIAccessKey;
amazonClient.secretKey = config.AmazonProductAdvertisingAPISecretKey; amazonClient.secretKey = config.AmazonProductAdvertisingAPISecretKey;
amazonClient.region = config.AmazonProductAdvertisingAPIRegion;
amazonClient.host = config.AmazonProductAdvertisingAPIHost; amazonClient.host = config.AmazonProductAdvertisingAPIHost;
amazonClient.region = config.AmazonProductAdvertisingAPIRegion;
var api = new ProductAdvertisingAPIv1.DefaultApi();
var searchItemsRequest = new ProductAdvertisingAPIv1.SearchItemsRequest();
searchItemsRequest.PartnerTag = config.AmazonProductAdvertisingAPIPartnerTag;
searchItemsRequest.PartnerType = config.AmazonProductAdvertisingAPIPartnerType;
searchItemsRequest.Keywords = "flashlight";
searchItemsRequest.Brand = "COAST";
searchItemsRequest.Marketplace = "www.amazon.com";
//Broken needs to be fixed:
searchItemsRequest.Resources = [
SearchItemsResourceValues["BrowseNodeInfo.BrowseNodes"],
SearchItemsResourceValues["BrowseNodeInfo.BrowseNodes.Ancestor"],
SearchItemsResourceValues["BrowseNodeInfo.BrowseNodes.SalesRank"],
SearchItemsResourceValues["BrowseNodeInfo.WebsiteSalesRank"],
SearchItemsResourceValues["CustomerReviews.Count"],
SearchItemsResourceValues["CustomerReviews.StarRating"],
SearchItemsResourceValues["Images.Primary.Small"],
SearchItemsResourceValues["Images.Primary.Medium"],
SearchItemsResourceValues["Images.Primary.Large"],
SearchItemsResourceValues["Images.Primary.HighRes"],
SearchItemsResourceValues["Images.Variants.Small"],
SearchItemsResourceValues["Images.Variants.Medium"],
SearchItemsResourceValues["Images.Variants.Large"],
SearchItemsResourceValues["Images.Variants.HighRes"],
SearchItemsResourceValues["ItemInfo.ByLineInfo"],
SearchItemsResourceValues["ItemInfo.ContentInfo"],
SearchItemsResourceValues["ItemInfo.ContentRating"],
SearchItemsResourceValues["ItemInfo.Classifications"],
SearchItemsResourceValues["ItemInfo.ExternalIds"],
SearchItemsResourceValues["ItemInfo.Features"],
SearchItemsResourceValues["ItemInfo.ManufactureInfo"],
SearchItemsResourceValues["ItemInfo.ProductInfo"],
SearchItemsResourceValues["ItemInfo.TechnicalInfo"],
SearchItemsResourceValues["ItemInfo.Title"],
SearchItemsResourceValues["ItemInfo.TradeInInfo"],
SearchItemsResourceValues["Offers.Listings.Availability.MaxOrderQuantity"],
SearchItemsResourceValues["Offers.Listings.Availability.Message"],
SearchItemsResourceValues["Offers.Listings.Availability.MinOrderQuantity"],
SearchItemsResourceValues["Offers.Listings.Availability.Type"],
SearchItemsResourceValues["Offers.Listings.Condition"],
SearchItemsResourceValues["Offers.Listings.Condition.ConditionNote"],
SearchItemsResourceValues["Offers.Listings.Condition.SubCondition"],
SearchItemsResourceValues["Offers.Listings.DeliveryInfo.IsAmazonFulfilled"],
SearchItemsResourceValues["Offers.Listings.DeliveryInfo.IsFreeShippingEligible"],
SearchItemsResourceValues["Offers.Listings.DeliveryInfo.IsPrimeEligible"],
SearchItemsResourceValues["Offers.Listings.DeliveryInfo.ShippingCharges"],
SearchItemsResourceValues["Offers.Listings.IsBuyBoxWinner"],
SearchItemsResourceValues["Offers.Listings.LoyaltyPoints.Points"],
SearchItemsResourceValues["Offers.Listings.MerchantInfo"],
SearchItemsResourceValues["Offers.Listings.Price"],
SearchItemsResourceValues["Offers.Listings.ProgramEligibility.IsPrimeExclusive"],
SearchItemsResourceValues["Offers.Listings.ProgramEligibility.IsPrimePantry"],
SearchItemsResourceValues["Offers.Listings.Promotions"],
SearchItemsResourceValues["Offers.Listings.SavingBasis"],
SearchItemsResourceValues["Offers.Summaries.HighestPrice"],
SearchItemsResourceValues["Offers.Summaries.LowestPrice"],
SearchItemsResourceValues["Offers.Summaries.OfferCount"],
SearchItemsResourceValues["ParentASIN"],
SearchItemsResourceValues["RentalOffers.Listings.Availability.MaxOrderQuantity"],
SearchItemsResourceValues["RentalOffers.Listings.Availability.Message"],
SearchItemsResourceValues["RentalOffers.Listings.Availability.MinOrderQuantity"],
SearchItemsResourceValues["RentalOffers.Listings.Availability.Type"],
SearchItemsResourceValues["RentalOffers.Listings.BasePrice"],
SearchItemsResourceValues["RentalOffers.Listings.Condition"],
SearchItemsResourceValues["RentalOffers.Listings.Condition.ConditionNote"],
SearchItemsResourceValues["RentalOffers.Listings.Condition.SubCondition"],
SearchItemsResourceValues["RentalOffers.Listings.DeliveryInfo.IsAmazonFulfilled"],
SearchItemsResourceValues["RentalOffers.Listings.DeliveryInfo.IsFreeShippingEligible"],
SearchItemsResourceValues["RentalOffers.Listings.DeliveryInfo.IsPrimeEligible"],
SearchItemsResourceValues["RentalOffers.Listings.DeliveryInfo.ShippingCharges"],
SearchItemsResourceValues["RentalOffers.Listings.MerchantInfo"],
SearchItemsResourceValues["SearchRefinements"],
];
// function onSuccess(data) {
// console.log('API called successfully.');
// var searchItemsResponse = ProductAdvertisingAPIv1.SearchItemsResponse.constructFromObject(data);
// console.log('Complete Response: \n' + JSON.stringify(searchItemsResponse, null, 1));
// if (searchItemsResponse['SearchResult'] !== undefined) {
// console.log('Printing First Item Information in SearchResult:');
// var item_0 = searchItemsResponse['SearchResult']['Items'][0];
// if (item_0 !== undefined) {
// if (item_0['ASIN'] !== undefined) {
// console.log('ASIN: ' + item_0['ASIN']);
// }
// if (item_0['DetailPageURL'] !== undefined) {
// console.log('DetailPageURL: ' + item_0['DetailPageURL']);
// }
// if (
// item_0['ItemInfo'] !== undefined &&
// item_0['ItemInfo']['Title'] !== undefined &&
// item_0['ItemInfo']['Title']['DisplayValue'] !== undefined
// ) {
// console.log('Title: ' + item_0['ItemInfo']['Title']['DisplayValue']);
// }
// if (
// item_0['Offers'] !== undefined &&
// item_0['Offers']['Listings'] !== undefined &&
// item_0['Offers']['Listings'][0]['Price'] !== undefined &&
// item_0['Offers']['Listings'][0]['Price']['DisplayAmount'] !== undefined
// ) {
// console.log('Buying Price: ' + item_0['Offers']['Listings'][0]['Price']['DisplayAmount']);
// }
// }
// }
// if (searchItemsResponse['Errors'] !== undefined) {
// console.log('Errors:');
// console.log('Complete Error Response: ' + JSON.stringify(searchItemsResponse['Errors'], null, 1));
// console.log('Printing 1st Error:');
// var error_0 = searchItemsResponse['Errors'][0];
// console.log('Error Code: ' + error_0['Code']);
// console.log('Error Message: ' + error_0['Message']);
// }
// }
// function onError(error) {
// console.log('Error calling PA-API 5.0!');
// console.log('Printing Full Error Object:\n' + JSON.stringify(error, null, 1));
// console.log('Status Code: ' + error['status']);
// if (error['response'] !== undefined && error['response']['text'] !== undefined) {
// console.log('Error Object: ' + JSON.stringify(error['response']['text'], null, 1));
// }
// }
// api.searchItems(searchItemsRequest).then(
// function(data) {
// onSuccess(data);
// },
// function(error) {
// onError(error);
// }
// );
// var getItems = new ProductAdvertisingAPIv1.GetItemsRequest();
//broken:
// getItems.Resources = [
// "",
// ]
// getItems.ItemIds
// import { SearchItemsResponse } from 'amazon-pa-api5-node-ts';
// import jsonSearchResults from '../brands/coast-query-response.json';
// const results: SearchItemsResponse = jsonSearchResults as SearchItemsResponse;
// for (let result of results.SearchResult!.Items!) {
// console.log(result.DetailPageURL);
// }

Some files were not shown because too many files have changed in this diff Show More