├── .nvmrc
├── .npmrc
├── packages
├── cli
│ ├── src
│ │ ├── pages
│ │ │ ├── index.ts
│ │ │ ├── Country.tsx
│ │ │ ├── Product
│ │ │ │ ├── Model
│ │ │ │ │ ├── DateView.tsx
│ │ │ │ │ ├── SizeItem.tsx
│ │ │ │ │ └── Model.tsx
│ │ │ │ └── Product.tsx
│ │ │ ├── Pages.tsx
│ │ │ └── Home.tsx
│ │ ├── layout
│ │ │ ├── index.ts
│ │ │ ├── Layout.tsx
│ │ │ ├── Footer.tsx
│ │ │ └── Header.tsx
│ │ ├── utils
│ │ │ ├── clearOutput.ts
│ │ │ ├── theme.ts
│ │ │ ├── useIsTooShortHeight.ts
│ │ │ ├── inputDictionary.ts
│ │ │ ├── useScreenSize.ts
│ │ │ ├── logger.ts
│ │ │ └── isEmpty.ts
│ │ ├── devEntry.ts
│ │ ├── index.tsx
│ │ ├── components
│ │ │ ├── Select
│ │ │ │ ├── SelectItem.tsx
│ │ │ │ ├── SelectIndicator.tsx
│ │ │ │ └── Select.tsx
│ │ │ ├── ChangeSizeScreen.tsx
│ │ │ └── Image.tsx
│ │ ├── hooks
│ │ │ └── useInputProcess.ts
│ │ ├── router.ts
│ │ └── store
│ │ │ ├── country.ts
│ │ │ ├── country.test.ts
│ │ │ └── product.ts
│ ├── tsconfig.json
│ ├── sea-config.json
│ ├── rspack.config.js
│ ├── scripts
│ │ ├── pack-sea.ps1
│ │ └── pack-sea.sh
│ └── package.json
└── sdk
│ ├── tsconfig.json
│ ├── mocks
│ ├── handlers
│ │ ├── handlers.ts
│ │ └── mockProductFeed
│ │ │ └── mockProductFeed.ts
│ └── node.ts
│ ├── CHANGELOG.md
│ ├── index.ts
│ ├── models
│ ├── snkrsRootResponse.ts
│ └── availableCountries.ts
│ ├── utils
│ ├── Error.ts
│ ├── delay.ts
│ ├── rest.ts
│ ├── HttpError.ts
│ ├── HttpError.test.ts
│ ├── Error.test.ts
│ ├── jsonRequest.ts
│ ├── delay.test.ts
│ └── jsonRequest.test.ts
│ ├── productFeed
│ ├── api.ts
│ ├── url.ts
│ ├── format.ts
│ └── schema.ts
│ ├── package.json
│ └── scripts
│ └── ci-check-product-feed.ts
├── .vscode
├── settings.json
└── launch.json
├── .gitignore
├── .prettierignore
├── .changeset
└── config.json
├── DEPENDENCIES.txt
├── vercel.json
├── .github
├── composite-actions
│ ├── install
│ │ └── action.yml
│ └── ensure-release-package
│ │ └── action.yml
└── workflows
│ ├── changesets-release-pr.yml
│ ├── product-feed-health.yml
│ ├── sdk-tarball-release.yml
│ └── cli-sea-release.yml
├── .prettierrc.json
├── tsconfig.json
├── api
└── upcoming-releases
│ └── [countryCode].ts
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 24.11.0
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | save-exact=true
3 | prefer-dedupe=true
--------------------------------------------------------------------------------
/packages/cli/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { Pages } from './Pages.ts'
2 |
--------------------------------------------------------------------------------
/packages/cli/src/layout/index.ts:
--------------------------------------------------------------------------------
1 | export { Layout } from './Layout.tsx'
2 |
--------------------------------------------------------------------------------
/packages/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["./src"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/sdk/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["./**/*.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "terminal.integrated.enableImages": true
4 | }
--------------------------------------------------------------------------------
/packages/cli/src/utils/clearOutput.ts:
--------------------------------------------------------------------------------
1 | export const clearOutput = () => {
2 | process.stdout.write('\u001b[3J\u001b[1J')
3 | console.clear()
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 | .yalc
5 | yalc.lock
6 |
7 | #db
8 | *.db*
9 | .vercel
10 | .env*.local
11 |
12 |
13 | #logs
14 | cli*.log
--------------------------------------------------------------------------------
/packages/sdk/mocks/handlers/handlers.ts:
--------------------------------------------------------------------------------
1 | import { mockProductFeed } from './mockProductFeed/mockProductFeed.ts'
2 |
3 | export const handlers = [...mockProductFeed]
4 |
--------------------------------------------------------------------------------
/packages/cli/src/devEntry.ts:
--------------------------------------------------------------------------------
1 | import { mockServer } from '@nike-release-checker/sdk/mocks'
2 |
3 | mockServer.listen({ onUnhandledRequest: 'bypass' })
4 | import('./index.tsx')
5 |
--------------------------------------------------------------------------------
/packages/sdk/mocks/node.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node'
2 |
3 | import { handlers } from './handlers/handlers.ts'
4 |
5 | export const mockServer = setupServer(...handlers)
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 | package-lock.json
4 |
5 | # build
6 | dist
7 |
8 | # code editors
9 | .idea
10 | .vscode
11 |
12 | # misc
13 | .yalc
14 | yalc.lock
15 | .DS_Store
--------------------------------------------------------------------------------
/packages/cli/src/utils/theme.ts:
--------------------------------------------------------------------------------
1 | export const theme = {
2 | color: {
3 | snkrsRed: '#FF5C7D',
4 | },
5 | sizes: {
6 | image: 5, // 5 for iTerm2, and 4 for VSCode due Image render bug, which adds extra empty lines to bottom
7 | fullHeight: 27,
8 | },
9 | }
10 |
--------------------------------------------------------------------------------
/packages/sdk/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @nike-release-checker/sdk
2 |
3 | ## 0.2.0
4 |
5 | ### Minor Changes
6 |
7 | - Add new handlers entprypoint to sdk package
8 |
9 | ### Patch Changes
10 |
11 | - 9315319: Seed SDK release tracking with the first Changeset entry.
12 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3 | "commit": false,
4 | "fixed": [],
5 | "linked": [],
6 | "access": "restricted",
7 | "baseBranch": "master",
8 | "ignore": ["@nike-release-checker/cli"]
9 | }
10 |
--------------------------------------------------------------------------------
/DEPENDENCIES.txt:
--------------------------------------------------------------------------------
1 | Dependency notes
2 | ----------------
3 | - Keep `ink-select-input` at 6.1.0. Do not bump to 6.2.0 or newer because 6.2.0 breaks number input processing. If a newer version is required, either propose a bugfix upstream or vendor the fixed source into this project.
4 |
5 |
--------------------------------------------------------------------------------
/packages/cli/sea-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "./dist/bundle.cjs",
3 | "output": "./dist/sea-prep.blob",
4 | "disableExperimentalSEAWarning": true,
5 | "useSnapshot": false,
6 | "useCodeCache": true,
7 | "execArgv": ["--experimental-webstorage", "--localstorage-file=local.db"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/useIsTooShortHeight.ts:
--------------------------------------------------------------------------------
1 | import { theme } from './theme.ts'
2 | import { useScreenSize } from './useScreenSize.ts'
3 |
4 | export const useIsTooShortHeight = (): boolean => {
5 | const { height } = useScreenSize()
6 | return height < theme.sizes.fullHeight
7 | }
8 |
--------------------------------------------------------------------------------
/packages/cli/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'ink'
2 |
3 | import { Layout } from './layout/index.ts'
4 | import { Pages } from './pages/Pages.tsx'
5 | import { clearOutput } from './utils/clearOutput.ts'
6 |
7 | clearOutput()
8 | render(
9 |
10 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "functions": {
4 | "api/**/*.ts": {
5 | "includeFiles": "packages/sdk/**"
6 | }
7 | },
8 | "rewrites": [
9 | {
10 | "source": "/api/upcoming-releases/:countryCode",
11 | "destination": "/api/upcoming-releases/[countryCode]"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/inputDictionary.ts:
--------------------------------------------------------------------------------
1 | export const inputDictionary = {
2 | HOME: {
3 | key: '1',
4 | routeName: 'home',
5 | url: '/',
6 | },
7 | COUNTRY: {
8 | key: '2',
9 | routeName: 'selectCountry',
10 | url: '/select-country',
11 | },
12 | PRODUCT: {
13 | key: '3',
14 | routeName: 'product',
15 | url: '/product',
16 | },
17 | } as const
18 |
--------------------------------------------------------------------------------
/packages/cli/src/components/Select/SelectItem.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from 'ink'
2 |
3 | import { theme } from '../../utils/theme.ts'
4 |
5 | export type SelectItemProps = {
6 | readonly isSelected?: boolean
7 | readonly label: string
8 | }
9 |
10 | export const SelectItem = ({ isSelected = false, label }: SelectItemProps) => {
11 | return {label}
12 | }
13 |
--------------------------------------------------------------------------------
/packages/sdk/index.ts:
--------------------------------------------------------------------------------
1 | export { getProductFeed } from './productFeed/api.ts'
2 | export { formatProductFeedResponse } from './productFeed/format.ts'
3 | export type { ProductFeedUrlParams } from './productFeed/url.ts'
4 | export { availableCountries } from './models/availableCountries.ts'
5 | export type * from './models/availableCountries.ts'
6 | export type * from './productFeed/schema.ts'
7 | export type * from './models/snkrsRootResponse.ts'
8 |
--------------------------------------------------------------------------------
/.github/composite-actions/install/action.yml:
--------------------------------------------------------------------------------
1 | name: Install workspace
2 | description: Checkout, set Node from .nvmrc, and install dependencies
3 | runs:
4 | using: composite
5 | steps:
6 | - name: Use Node version from .nvmrc
7 | uses: actions/setup-node@v6
8 | with:
9 | node-version-file: ".nvmrc"
10 | cache: npm
11 |
12 | - name: Install dependencies
13 | run: npm ci
14 | shell: bash
15 |
--------------------------------------------------------------------------------
/packages/sdk/mocks/handlers/mockProductFeed/mockProductFeed.ts:
--------------------------------------------------------------------------------
1 | import { delay, http, HttpResponse } from 'msw'
2 |
3 | import { mockGetProductFeedData } from './mockProductFeedData.ts'
4 |
5 | const mockGetProductFeed = http.get(`https://api.nike.com/product_feed/threads/v3/`, async () => {
6 | const body = mockGetProductFeedData
7 | await delay()
8 | return HttpResponse.json(body)
9 | })
10 |
11 | export const mockProductFeed = [mockGetProductFeed]
12 |
--------------------------------------------------------------------------------
/packages/cli/src/hooks/useInputProcess.ts:
--------------------------------------------------------------------------------
1 | import { useInput } from 'ink'
2 |
3 | import { $router } from '../router.ts'
4 | import { $country } from '../store/country.ts'
5 | import { inputDictionary } from '../utils/inputDictionary.ts'
6 |
7 | const { HOME, COUNTRY } = inputDictionary
8 |
9 | export const useInputProcess = () => {
10 | useInput((input) => {
11 | if (input === HOME.key) $router.open(HOME.url)
12 | else if (input === COUNTRY.key) $country.reset()
13 | })
14 | }
15 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "useTabs": true,
4 | "singleQuote": true,
5 | "semi": false,
6 | "trailingComma": "es5",
7 | "plugins": ["@ianvs/prettier-plugin-sort-imports"],
8 | "importOrder": [
9 | "",
10 | "",
11 | "^react",
12 | "",
13 | "",
14 | "^[.]",
15 | "",
16 | "",
17 | "^[.]"
18 | ],
19 | "importOrderParserPlugins": ["typescript", "jsx", "importAttributes"],
20 | "importOrderTypeScriptVersion": "5.0.0"
21 | }
22 |
--------------------------------------------------------------------------------
/packages/cli/src/components/Select/SelectIndicator.tsx:
--------------------------------------------------------------------------------
1 | import figures from 'figures'
2 | import { Box, Text } from 'ink'
3 |
4 | import { theme } from '../../utils/theme.ts'
5 |
6 | export type SelectIndicatorProps = {
7 | readonly isSelected?: boolean
8 | }
9 |
10 | export const SelectIndicator = ({ isSelected = false }: SelectIndicatorProps) => {
11 | return (
12 |
13 | {isSelected ? {figures.pointer} : }
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/sdk/models/snkrsRootResponse.ts:
--------------------------------------------------------------------------------
1 | import { array, number, object, string } from 'valibot'
2 |
3 | import type { BaseSchema } from 'valibot'
4 |
5 | const pagesSchema = object({
6 | next: string(),
7 | prev: string(),
8 | totalPages: number(),
9 | totalResources: number(),
10 | })
11 |
12 | export const createSnkrsRootResponseSchema = >(
13 | objectsSchema: Schema
14 | ) =>
15 | object({
16 | objects: array(objectsSchema),
17 | pages: pagesSchema,
18 | })
19 |
--------------------------------------------------------------------------------
/packages/cli/src/components/Select/Select.tsx:
--------------------------------------------------------------------------------
1 | import SelectInput from 'ink-select-input'
2 |
3 | import { SelectIndicator } from './SelectIndicator.tsx'
4 | import { SelectItem } from './SelectItem.tsx'
5 |
6 | import type { ComponentProps } from 'react'
7 |
8 | export const Select = ({
9 | itemComponent = SelectItem,
10 | indicatorComponent = SelectIndicator,
11 | ...rest
12 | }: ComponentProps>) => {
13 | return (
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/sdk/utils/Error.ts:
--------------------------------------------------------------------------------
1 | export class CustomError extends Error {
2 | static isAbortError(error: unknown) {
3 | return error instanceof Error && error.name === 'AbortError'
4 | }
5 |
6 | static createAbortError(reason?: any) {
7 | return reason ?? new DOMException('This operation was aborted', 'AbortError')
8 | }
9 |
10 | static isTimeoutError(error: unknown) {
11 | return error instanceof Error && error.name === 'TimeoutError'
12 | }
13 |
14 | constructor(message: string) {
15 | super(message)
16 | this.name = this.constructor.name
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/sdk/utils/delay.ts:
--------------------------------------------------------------------------------
1 | import { CustomError } from './Error.ts'
2 |
3 | export const delay = (ms: number, { signal }: { signal?: AbortSignal } = {}) => {
4 | const { promise, resolve, reject } = Promise.withResolvers()
5 |
6 | const abortHandler = () => {
7 | clearTimeout(timeout)
8 | reject(CustomError.createAbortError(signal?.reason))
9 | }
10 | signal?.addEventListener('abort', abortHandler, { once: true })
11 |
12 | const timeout = setTimeout(() => {
13 | signal?.removeEventListener('abort', abortHandler)
14 | resolve(undefined)
15 | }, ms)
16 |
17 | return promise
18 | }
19 |
--------------------------------------------------------------------------------
/packages/sdk/productFeed/api.ts:
--------------------------------------------------------------------------------
1 | import { rest } from '../utils/rest.ts'
2 | import { getProductFeedUrl } from './url.ts'
3 |
4 | import type { GetOptions } from '../utils/rest.ts'
5 | import type { ProductFeedResponseOutput } from './schema.ts'
6 | import type { ProductFeedUrlParams } from './url.ts'
7 |
8 | type GetProductFeedParams = ProductFeedUrlParams & Omit
9 |
10 | export const getProductFeed = async (params: GetProductFeedParams) => {
11 | const url = getProductFeedUrl(params)
12 | const response = await rest.get({ url, ...params })
13 | return response.objects
14 | }
15 |
--------------------------------------------------------------------------------
/packages/cli/src/layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Spacer } from 'ink'
2 |
3 | import { useInputProcess } from '../hooks/useInputProcess.ts'
4 | import { theme } from '../utils/theme.ts'
5 | import { Footer } from './Footer.tsx'
6 | import { Header } from './Header.tsx'
7 |
8 | import type { ReactElement } from 'react'
9 |
10 | interface LayoutProps {
11 | children: ReactElement
12 | }
13 |
14 | export const Layout = ({ children }: LayoutProps) => {
15 | useInputProcess()
16 |
17 | return (
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/packages/sdk/utils/rest.ts:
--------------------------------------------------------------------------------
1 | import { jsonRequest } from './jsonRequest.ts'
2 |
3 | import type { RequestOptions } from './jsonRequest.ts'
4 |
5 | export type GetOptions = Pick
6 |
7 | export interface PostOptions extends GetOptions {
8 | body: any
9 | }
10 |
11 | const get = async (options: GetOptions): Promise => {
12 | return jsonRequest({ ...options, method: 'GET' })
13 | }
14 |
15 | const post = async (options: PostOptions): Promise => {
16 | return jsonRequest({ ...options, method: 'POST' })
17 | }
18 |
19 | export const rest = {
20 | get,
21 | post,
22 | }
23 |
--------------------------------------------------------------------------------
/packages/cli/src/router.ts:
--------------------------------------------------------------------------------
1 | import { createRouter } from '@nanostores/router'
2 |
3 | import { inputDictionary } from './utils/inputDictionary.ts'
4 | import { logger } from './utils/logger.ts'
5 |
6 | const { HOME, COUNTRY, PRODUCT } = inputDictionary
7 |
8 | export const $router = createRouter({
9 | [COUNTRY.routeName]: COUNTRY.url,
10 | [HOME.routeName]: HOME.url,
11 | [PRODUCT.routeName]: PRODUCT.url,
12 | })
13 |
14 | const LOG_SCOPE = 'router'
15 | logger.debug('router initialized', { scope: LOG_SCOPE, routes: Object.keys($router.routes) })
16 |
17 | $router.listen((routerState) => {
18 | logger.debug('router state change', {
19 | scope: LOG_SCOPE,
20 | route: routerState?.route,
21 | params: routerState?.params,
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Country.tsx:
--------------------------------------------------------------------------------
1 | import { availableCountries } from '@nike-release-checker/sdk'
2 | import { Box, Text } from 'ink'
3 |
4 | import { Select } from '../components/Select/Select.tsx'
5 | import { $country } from '../store/country.ts'
6 | import { theme } from '../utils/theme.ts'
7 |
8 | const countryItems = availableCountries.map(({ code, name, description }) => ({
9 | label: description ? `${name} (${description})` : name,
10 | value: code,
11 | }))
12 |
13 | export const Country = () => (
14 |
15 | Select Country:
16 |
22 | )
23 |
--------------------------------------------------------------------------------
/.github/workflows/changesets-release-pr.yml:
--------------------------------------------------------------------------------
1 | name: Changesets Release PR
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: write
11 | pull-requests: write
12 |
13 | jobs:
14 | release:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v6
20 | with:
21 | fetch-depth: 0 # need full history for tags/changesets
22 | fetch-tags: true
23 |
24 | - name: Install
25 | uses: ./.github/composite-actions/install
26 |
27 | - name: Create or update release PR
28 | uses: changesets/action@v1
29 | with:
30 | commit: "chore: release"
31 | title: "chore: release"
32 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/useScreenSize.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react'
2 | import { useStdout } from 'ink'
3 |
4 | /**
5 | * Returns an up to date screen size object with `height` and `width` properties
6 | * that reflect the current size of the terminal (rows and columns in stdout).
7 | */
8 | export const useScreenSize = () => {
9 | const { stdout } = useStdout()
10 | const getSize = useCallback(() => ({ height: stdout.rows, width: stdout.columns }), [stdout])
11 | const [size, setSize] = useState(getSize)
12 | useEffect(() => {
13 | function onResize() {
14 | setSize(getSize())
15 | }
16 | stdout.on('resize', onResize)
17 | return () => {
18 | stdout.off('resize', onResize)
19 | }
20 | }, [stdout, getSize])
21 | return size
22 | }
23 |
--------------------------------------------------------------------------------
/packages/cli/src/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '@nanostores/react'
2 | import { Box, Text } from 'ink'
3 |
4 | import { $router } from '../router.ts'
5 | import { inputDictionary } from '../utils/inputDictionary.ts'
6 |
7 | const { HOME, COUNTRY, PRODUCT } = inputDictionary
8 |
9 | export const Footer = () => {
10 | const page = useStore($router)
11 |
12 | return (
13 |
14 |
15 | {page?.route === PRODUCT.routeName && [{HOME.key}] - Home}
16 | {page?.route !== COUNTRY.routeName && [{COUNTRY.key}] - Reset Country}
17 |
18 |
19 | Made by @whoisYeshua
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Product/Model/DateView.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from 'ink'
2 |
3 | const formatLocaleDate = (dateString: string | undefined, language?: string): string => {
4 | if (!dateString) return 'N/A'
5 |
6 | try {
7 | const date = new Date(dateString)
8 | return date.toLocaleString(language, {
9 | year: 'numeric',
10 | month: 'short',
11 | day: 'numeric',
12 | hour: '2-digit',
13 | minute: '2-digit',
14 | second: '2-digit',
15 | fractionalSecondDigits: 3,
16 | timeZoneName: 'long',
17 | })
18 | } catch {
19 | return dateString
20 | }
21 | }
22 |
23 | export const DateView = ({ date, type }: { date: string | undefined; type: 'start' | 'end' }) => {
24 | return (
25 |
26 |
27 | {type === 'start' ? 'Start:' : 'End:'}
28 |
29 | {formatLocaleDate(date)}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Product/Model/SizeItem.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from 'ink'
2 |
3 | import type { LevelOutput } from '@nike-release-checker/sdk'
4 | import type { TextProps } from 'ink'
5 |
6 | interface SizeItemProps {
7 | size: string
8 | stock: LevelOutput
9 | }
10 |
11 | const stockColorMap = {
12 | HIGH: 'green',
13 | MEDIUM: 'yellow',
14 | LOW: 'red',
15 | OOS: 'gray',
16 | NA: 'black',
17 | } satisfies Record
18 |
19 | export const SizeItem = ({ size, stock }: SizeItemProps) => {
20 | const color = stockColorMap[stock] ?? 'gray'
21 | const bold = color === 'green'
22 |
23 | return (
24 |
25 |
26 |
27 | {size}
28 |
29 |
30 |
31 |
32 | - {stock}
33 |
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 |
3 | import { createLogger, format } from 'winston'
4 | import DailyRotateFile from 'winston-daily-rotate-file'
5 |
6 | const logLevel = process.env.LOG_LEVEL ?? 'info'
7 | const logFilePath = path.join(process.cwd(), 'cli-%DATE%.log')
8 |
9 | const fileTransport = new DailyRotateFile({
10 | filename: logFilePath,
11 | maxSize: '50m',
12 | })
13 |
14 | export const logger = createLogger({
15 | level: logLevel,
16 | format: format.combine(
17 | format.timestamp(),
18 | format.printf(({ timestamp, level, message, scope = 'app', ...rest }) => {
19 | const meta = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ''
20 | return `${timestamp} [${scope}] ${level}: ${message}${meta}`
21 | })
22 | ),
23 | // Keep payload lean; pino used `base: undefined`.
24 | defaultMeta: undefined,
25 | transports: [fileTransport],
26 | })
27 |
--------------------------------------------------------------------------------
/packages/sdk/productFeed/url.ts:
--------------------------------------------------------------------------------
1 | import type { CountryCode, CountryLanguage } from '../models/availableCountries.ts'
2 |
3 | export interface ProductFeedUrlParams {
4 | countryCode: CountryCode
5 | language: CountryLanguage
6 | channelId?: string
7 | upcoming?: boolean
8 | }
9 |
10 | export const getProductFeedUrl = ({
11 | countryCode,
12 | language,
13 | channelId = '010794e5-35fe-4e32-aaff-cd2c74f89d61',
14 | upcoming = true,
15 | }: ProductFeedUrlParams) => {
16 | const url = new URL('https://api.nike.com/product_feed/threads/v3/')
17 | url.searchParams.append('filter', `marketplace(${countryCode})`)
18 | url.searchParams.append('filter', `language(${language})`)
19 | url.searchParams.append('filter', `channelId(${channelId})`)
20 | url.searchParams.append('filter', `upcoming(${upcoming})`)
21 | url.searchParams.append('filter', 'exclusiveAccess(true,false)')
22 |
23 | return url
24 | }
25 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Pages.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '@nanostores/react'
2 |
3 | import { ChangeSizeScreen } from '../components/ChangeSizeScreen.tsx'
4 | import { $router } from '../router.ts'
5 | import { inputDictionary } from '../utils/inputDictionary.ts'
6 | import { useIsTooShortHeight } from '../utils/useIsTooShortHeight.ts'
7 | import { Country } from './Country.tsx'
8 | import { Home } from './Home.tsx'
9 | import { Product } from './Product/Product.tsx'
10 |
11 | const { HOME, COUNTRY, PRODUCT } = inputDictionary
12 |
13 | export const Pages = () => {
14 | const page = useStore($router)
15 | const isTooShortHeight = useIsTooShortHeight()
16 |
17 | if (isTooShortHeight) return
18 | if (!page) return null
19 | if (page.route === HOME.routeName) return
20 | if (page.route === COUNTRY.routeName) return
21 | if (page.route === PRODUCT.routeName) return
22 | return null
23 | }
24 |
--------------------------------------------------------------------------------
/packages/sdk/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@nike-release-checker/sdk",
3 | "version": "0.2.0",
4 | "type": "module",
5 | "exports": {
6 | ".": "./index.ts",
7 | "./mocks": "./mocks/node.ts",
8 | "./mocks/handlers": "./mocks/handlers/handlers.ts"
9 | },
10 | "files": [
11 | "mocks",
12 | "models",
13 | "productFeed",
14 | "utils/**/!(*.test).ts",
15 | "index.ts"
16 | ],
17 | "scripts": {
18 | "ci:check-product-feed": "node scripts/ci-check-product-feed.ts",
19 | "format": "npm run format:prettier",
20 | "format:prettier": "npm run lint:prettier -- --write",
21 | "lint": "npm run lint:prettier && npm run lint:ts",
22 | "lint:prettier": "prettier \"**/*.{json,[jt]s,[jt]sx,[cm][jt]s}\" --check",
23 | "lint:ts": "tsc --noEmit --project tsconfig.json",
24 | "test": "node --experimental-webstorage --localstorage-file=test.db --no-warnings --test"
25 | },
26 | "dependencies": {
27 | "valibot": "1.2.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 | /* Language and Environment */
5 | "target": "esnext",
6 | "jsx": "react-jsx",
7 |
8 | /* Modules */
9 | "module": "nodenext" /* Specify what module code is generated. */,
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 |
13 | /* Emit */
14 | "noEmit": true,
15 |
16 | /* Interop Constraints */
17 | "isolatedModules": true,
18 | "allowSyntheticDefaultImports": true,
19 | "esModuleInterop": false,
20 | "forceConsistentCasingInFileNames": true,
21 | "erasableSyntaxOnly": true,
22 | "verbatimModuleSyntax": true,
23 |
24 | /* Type Checking */
25 | "strict": true,
26 | "noUnusedLocals": true,
27 | "noUnusedParameters": true,
28 | "noFallthroughCasesInSwitch": true,
29 |
30 | /* Completeness */
31 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
32 | },
33 | }
34 |
--------------------------------------------------------------------------------
/packages/cli/src/components/ChangeSizeScreen.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from 'ink'
2 |
3 | import { theme } from '../utils/theme.ts'
4 | import { useScreenSize } from '../utils/useScreenSize.ts'
5 |
6 | export const ChangeSizeScreen = () => {
7 | const { height } = useScreenSize()
8 |
9 | const HEADER_AND_FOOTER_ROWS = 6
10 |
11 | return (
12 |
17 |
18 | CHANGE TERMINAL\WINDOW HEIGHT
19 |
20 |
21 | Current height:{' '}
22 |
23 | {height}
24 |
25 |
26 |
27 | Minimum required height:{' '}
28 |
29 | {theme.sizes.fullHeight}
30 |
31 |
32 |
33 | You should increase the height by {theme.sizes.fullHeight - height}{' '}
34 | lines
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/packages/cli/rspack.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@rspack/cli'
2 | import { rspack } from '@rspack/core'
3 |
4 | export default defineConfig({
5 | entry: './src/index.tsx',
6 | target: 'node24.11',
7 | output: {
8 | filename: 'bundle.cjs',
9 | chunkFormat: 'commonjs',
10 | },
11 | devtool: false,
12 | optimization: {
13 | minimize: false,
14 | },
15 | plugins: [
16 | new rspack.DefinePlugin({
17 | 'process.env.DEV': false,
18 | 'process.browser': false,
19 | 'process.env.ENVIRONMENT': JSON.stringify('NODE'),
20 | }),
21 | ],
22 | module: {
23 | rules: [
24 | {
25 | test: /\.(jsx?|tsx?)$/,
26 | use: [
27 | {
28 | loader: 'builtin:swc-loader',
29 | options: {
30 | jsc: {
31 | parser: {
32 | syntax: 'typescript',
33 | tsx: true,
34 | },
35 | transform: {
36 | react: {
37 | runtime: 'automatic',
38 | },
39 | },
40 | },
41 | },
42 | },
43 | ],
44 | },
45 | ],
46 | },
47 | })
48 |
--------------------------------------------------------------------------------
/packages/cli/src/components/Image.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { Text } from 'ink'
3 | import terminalImage from 'terminal-image'
4 |
5 | export type ImageProps = {
6 | readonly src: ArrayBuffer | string
7 | readonly width?: number | string
8 | readonly height?: number | string
9 | readonly preserveAspectRatio?: boolean
10 | }
11 |
12 | export const Image = ({
13 | src,
14 | width = '100%',
15 | height = '100%',
16 | preserveAspectRatio = true,
17 | }: ImageProps) => {
18 | const [imageData, setImageData] = useState('')
19 |
20 | useEffect(() => {
21 | ;(async () => {
22 | let imageData
23 | if (src instanceof ArrayBuffer) {
24 | const buffer = Buffer.from(src)
25 | imageData = await terminalImage.buffer(buffer, { width, height, preserveAspectRatio })
26 | } else {
27 | imageData = await terminalImage.file(src, { width, height, preserveAspectRatio })
28 | }
29 | setImageData(imageData)
30 | })()
31 | }, [src, width, height, preserveAspectRatio])
32 |
33 | return {imageData}
34 | }
35 |
--------------------------------------------------------------------------------
/packages/cli/src/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '@nanostores/react'
2 | import { Box, Text } from 'ink'
3 |
4 | import packageJson from '../../package.json' with { type: 'json' }
5 | import { $country } from '../store/country.ts'
6 | import { $selectedProductImage } from '../store/product.ts'
7 | import { theme } from '../utils/theme.ts'
8 |
9 | const { version } = packageJson
10 |
11 | export const Header = () => {
12 | const country = useStore($country.readableValue)
13 | const selectedProductImage = useStore($selectedProductImage)
14 | const isImageShown = Boolean(selectedProductImage?.data)
15 |
16 | return (
17 |
24 |
25 |
26 | SNKRS CLI
27 |
28 |
29 | V {version}
30 |
31 |
32 |
33 | Country: {country}
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/packages/sdk/utils/HttpError.ts:
--------------------------------------------------------------------------------
1 | import { CustomError } from './Error.ts'
2 |
3 | export class HttpError extends CustomError {
4 | statusCode: number
5 |
6 | constructor(statusCode: number, message?: string) {
7 | const defaultMessage = `HTTP error code: ${statusCode}`
8 | super(message ? message : defaultMessage)
9 | this.statusCode = statusCode
10 | }
11 | }
12 |
13 | // Extend HTTPError to handle 429 specifically
14 | export class RateLimitError extends HttpError {
15 | static statusCode = 429
16 |
17 | retryAfter: number // ms
18 |
19 | constructor(response: Response) {
20 | const retryAfterSeconds = Math.abs(parseInt(response.headers.get('Retry-After') ?? '5', 10)) // Default to 5 seconds if Retry-After is not present
21 | super(
22 | RateLimitError.statusCode,
23 | `Too many requests to resource, cooldown in ${retryAfterSeconds} seconds.`
24 | )
25 | this.retryAfter = retryAfterSeconds * 1000
26 | }
27 |
28 | static fromResponse(response: Response): RateLimitError | null {
29 | if (response.status === RateLimitError.statusCode) {
30 | return new RateLimitError(response)
31 | }
32 | return null
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "(tsx) Attach to process",
6 | "type": "node",
7 | "request": "attach",
8 | "port": 9229,
9 | "skipFiles": [
10 | // Node.js internal core modules
11 | "/**",
12 |
13 | // Ignore all dependencies (optional)
14 | "${workspaceFolder}/node_modules/**",
15 | ],
16 | },
17 | {
18 | "name": "tsx current file",
19 | "type": "node",
20 | "request": "launch",
21 |
22 | // Debug current file in VSCode
23 | "program": "${file}",
24 |
25 | /*
26 | * Path to tsx binary
27 | * Assuming locally installed
28 | */
29 | "runtimeExecutable": "tsx",
30 |
31 | /*
32 | * Open terminal when debugging starts (Optional)
33 | * Useful to see console.logs
34 | */
35 | "console": "integratedTerminal",
36 | "internalConsoleOptions": "neverOpen",
37 |
38 | // Files to exclude from debugger (e.g. call stack)
39 | "skipFiles": [
40 | // Node.js internal core modules
41 | "/**",
42 |
43 | // Ignore all dependencies (optional)
44 | "${workspaceFolder}/node_modules/**",
45 | ],
46 | },
47 | ],
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/product-feed-health.yml:
--------------------------------------------------------------------------------
1 | name: Product feed health
2 |
3 | on:
4 | schedule:
5 | - cron: "0 */12 * * *"
6 | workflow_dispatch:
7 |
8 | jobs:
9 | check:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v6
15 |
16 | - name: Install
17 | uses: ./.github/composite-actions/install
18 |
19 | - name: Restore product feed state
20 | id: state-cache-restore
21 | uses: actions/cache/restore@v4
22 | with:
23 | path: .gh-cache/product-feed-state.json
24 | key: product-feed-state-${{ runner.os }}-v1-${{ github.run_id }}
25 | restore-keys: |
26 | product-feed-state-${{ runner.os }}-v1-
27 |
28 | - name: Run product feed check
29 | run: npm run ci:check-product-feed
30 |
31 | - name: Save product feed state
32 | # Save state even on failure so the next run continues the rotation instead of repeating
33 | if: always()
34 | uses: actions/cache/save@v4
35 | with:
36 | path: .gh-cache/product-feed-state.json
37 | key: product-feed-state-${{ runner.os }}-v1-${{ github.run_id }}
38 |
39 |
40 |
--------------------------------------------------------------------------------
/api/upcoming-releases/[countryCode].ts:
--------------------------------------------------------------------------------
1 | import {
2 | availableCountries,
3 | formatProductFeedResponse,
4 | getProductFeed,
5 | } from '@nike-release-checker/sdk'
6 |
7 | const getTargetCountry = (countryCode: string) =>
8 | availableCountries.find(({ code }) => code === countryCode)
9 |
10 | export async function GET(request: Request) {
11 | const url = new URL(request.url)
12 | const pathMatch = new URLPattern({ pathname: '/api/upcoming-releases/:countryCode' }).exec(url)
13 | const countryCode = pathMatch?.pathname.groups.countryCode?.toUpperCase()
14 | const targetCountry = countryCode ? getTargetCountry(countryCode) : null
15 |
16 | if (!targetCountry) {
17 | const acceptableCountries = availableCountries.map(({ code }) => code).join(', ')
18 | return Response.json(
19 | { error: `Country not found. Acceptable countries: ${acceptableCountries}` },
20 | { status: 400 }
21 | )
22 | }
23 |
24 | try {
25 | const data = await getProductFeed({
26 | countryCode: targetCountry.code,
27 | language: targetCountry.language,
28 | })
29 |
30 | const formatted = formatProductFeedResponse(data)
31 | return Response.json(formatted, { status: 200 })
32 | } catch (error) {
33 | console.error(error)
34 | return Response.json({ error: 'Internal Server Error' }, { status: 500 })
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/cli/scripts/pack-sea.ps1:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env pwsh
2 | $ErrorActionPreference = "Stop"
3 |
4 | $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
5 | $ProjectRoot = Resolve-Path (Join-Path $ScriptDir "..")
6 |
7 | $SeaConfig = Join-Path $ProjectRoot "sea-config.json"
8 | $DistDir = Join-Path $ProjectRoot "dist"
9 | $Bundle = Join-Path $DistDir "bundle.cjs"
10 | $Blob = Join-Path $DistDir "sea-prep.blob"
11 | $OutputBin = Join-Path $DistDir "nike-release-checker-win-x64.exe"
12 |
13 | # Clean previous artifacts
14 | New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
15 | Remove-Item -Force $Blob, $OutputBin -ErrorAction SilentlyContinue
16 |
17 | Write-Host "Building bundle to $Bundle"
18 | npm run build --prefix $ProjectRoot
19 |
20 | if (-not (Test-Path $Bundle)) {
21 | Write-Error "Bundle not found at $Bundle"
22 | }
23 |
24 | Write-Host "Generating SEA blob using config file $SeaConfig"
25 | node --experimental-sea-config $SeaConfig
26 |
27 | $NodeBin = (Get-Command node).Source
28 | Write-Host "Copying Node binary $NodeBin to $OutputBin"
29 | Copy-Item $NodeBin $OutputBin -Force
30 |
31 | Write-Host "Injecting SEA blob..."
32 | npx postject $OutputBin NODE_SEA_BLOB $Blob `
33 | --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
34 |
35 | Write-Host "SEA executable ready at $OutputBin"
36 |
37 |
38 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/isEmpty.ts:
--------------------------------------------------------------------------------
1 | declare const emptyObjectSymbol: unique symbol
2 | interface EmptyObject {
3 | [emptyObjectSymbol]?: never
4 | }
5 | type Empty = null | undefined | void | '' | never | never[] | EmptyObject
6 | interface NullObject {
7 | valueOf(): null
8 | }
9 |
10 | export const isEmptyArray = (variable: unknown) => Array.isArray(variable) && variable.length === 0
11 |
12 | export const isVoid = (
13 | variable: unknown
14 | ): variable is null | undefined | void | never | NullObject =>
15 | variable === undefined ||
16 | (typeof variable === 'object' && (variable === null || variable.valueOf() === null))
17 |
18 | export const isEmptyString = (variable: unknown) => typeof variable === 'string' && variable === ''
19 |
20 | export const isEmptyObject = (variable: unknown): variable is EmptyObject | NullObject =>
21 | typeof variable === 'object' &&
22 | variable !== null &&
23 | ((variable.constructor.prototype === Object.prototype &&
24 | Object.getOwnPropertyNames(variable).length === 0) ||
25 | variable.valueOf() === null)
26 |
27 | export const isEmpty = (variable: T | Empty): variable is Empty =>
28 | isVoid(variable) || isEmptyString(variable) || isEmptyArray(variable) || isEmptyObject(variable)
29 |
30 | export const isNotEmpty = (variable: T | Empty): variable is Exclude =>
31 | !isEmpty(variable)
32 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useStore } from '@nanostores/react'
3 | import { Box, Text } from 'ink'
4 | import Spinner from 'ink-spinner'
5 |
6 | import { Select } from '../components/Select/Select.tsx'
7 | import { $products, $selectedProductSlug } from '../store/product.ts'
8 | import { isEmpty } from '../utils/isEmpty.ts'
9 | import { theme } from '../utils/theme.ts'
10 |
11 | export const Home = () => {
12 | const { loading, data } = useStore($products.value)
13 | const productsList = useMemo(
14 | () =>
15 | data?.map((product) => ({
16 | value: product.slug,
17 | label: `${product.title} (${product.slug})`,
18 | })) ?? [],
19 | [data]
20 | )
21 |
22 | if (loading) return
23 |
24 | if (isEmpty(productsList)) return
25 |
26 | return (
27 |
28 | Select Product:
29 |
35 | )
36 | }
37 |
38 | const EmptyProductsElement = () => No products found
39 |
40 | const LoadingElement = () => (
41 |
42 |
43 |
44 |
45 | Loading
46 |
47 | )
48 |
--------------------------------------------------------------------------------
/packages/cli/src/pages/Product/Model/Model.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '@nanostores/react'
2 | import { Box, Text } from 'ink'
3 |
4 | import { $selectedModel } from '../../../store/product.ts'
5 | import { DateView } from './DateView.tsx'
6 | import { SizeItem } from './SizeItem.tsx'
7 |
8 | export const Model = () => {
9 | const model = useStore($selectedModel.value)
10 |
11 | if (!model) return No model available
12 |
13 | return (
14 |
15 |
16 |
17 | {model.modelName}
18 | {' '}
19 | (id: {model.id})
20 |
21 |
22 |
23 | Method: {model.launchView?.method ?? 'N/A'}{' '}
24 |
25 |
26 | Price: {model.merchPrice.currentPrice}{' '}
27 | {model.merchPrice.currency}
28 |
29 |
30 |
31 |
32 |
33 |
34 | Size - stock:
35 |
36 | {model.sizes.map((size) => (
37 |
38 | ))}
39 |
40 |
41 | )
42 | }
43 |
44 | const Br = () =>
45 |
--------------------------------------------------------------------------------
/packages/cli/src/store/country.ts:
--------------------------------------------------------------------------------
1 | import { persistentAtom } from '@nanostores/persistent'
2 | import { availableCountries } from '@nike-release-checker/sdk'
3 | import { computed } from 'nanostores'
4 |
5 | import { $router } from '../router.ts'
6 | import { inputDictionary } from '../utils/inputDictionary.ts'
7 | import { logger } from '../utils/logger.ts'
8 |
9 | import type { CountryCode } from '@nike-release-checker/sdk'
10 |
11 | const { HOME, COUNTRY } = inputDictionary
12 |
13 | export const createCountry = () => {
14 | const LOG_SCOPE = 'country'
15 | const $country = persistentAtom<(typeof availableCountries)[number] | null>('country', null, {
16 | encode: JSON.stringify,
17 | decode: JSON.parse,
18 | })
19 |
20 | $country.subscribe((country) => {
21 | logger.info('country selected', { scope: LOG_SCOPE, country })
22 | $router.open(country ? HOME.url : COUNTRY.url)
23 | })
24 |
25 | return {
26 | get value(): typeof $country {
27 | return $country
28 | },
29 | set value(countryCode: CountryCode) {
30 | const targetCountry = availableCountries.find(({ code }) => countryCode === code)
31 | if (!targetCountry) {
32 | logger.info('country code not found', { scope: LOG_SCOPE, countryCode })
33 | return
34 | }
35 |
36 | $country.set(targetCountry)
37 | },
38 | reset: () => {
39 | logger.info('country reset requested', { scope: LOG_SCOPE })
40 | $country.set(null)
41 | },
42 | readableValue: computed($country, (countryObj) =>
43 | countryObj ? countryObj.name : 'Not Selected'
44 | ),
45 | }
46 | }
47 |
48 | export const $country = createCountry()
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nike-release-checker",
3 | "description": "Simple terminal Nike stock checker (50+ countries)",
4 | "version": "0.1.0",
5 | "type": "module",
6 | "workspaces": [
7 | "packages/*"
8 | ],
9 | "scripts": {
10 | "start": "npm run start --workspace @nike-release-checker/cli",
11 | "start:debug": "npm run start:debug --workspace @nike-release-checker/cli",
12 | "start:remote-debug": "npm run start:remote-debug --workspace @nike-release-checker/cli",
13 | "start:watch": "npm run start:watch --workspace @nike-release-checker/cli",
14 | "build": "npm run build --workspace @nike-release-checker/cli",
15 | "build:sea": "npm run build:sea --workspace @nike-release-checker/cli",
16 | "build:check": "npm run build:check --workspace @nike-release-checker/cli",
17 | "vercel:build": "npm run vercel:build --workspace @nike-release-checker/cli",
18 | "test": "npm run test --workspaces",
19 | "format": "npm run format --workspaces",
20 | "lint": "npm run lint --workspaces",
21 | "changeset": "changeset",
22 | "changeset:version": "changeset version && npm i",
23 | "changeset:publish": "changeset tag",
24 | "ci:check-product-feed": "npm run ci:check-product-feed --workspace @nike-release-checker/sdk"
25 | },
26 | "keywords": [
27 | "api",
28 | "snkrs",
29 | "terminal",
30 | "stock",
31 | "nike"
32 | ],
33 | "author": "whoisYeshua",
34 | "license": "ISC",
35 | "devDependencies": {
36 | "@changesets/cli": "2.29.8",
37 | "@ianvs/prettier-plugin-sort-imports": "4.7.0",
38 | "@types/node": "24.10.1",
39 | "@vercel/node": "5.5.15",
40 | "prettier": "3.7.4",
41 | "typescript": "5.9.3"
42 | },
43 | "engines": {
44 | "node": "24.x"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/sdk/utils/HttpError.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { describe, test } from 'node:test'
3 |
4 | import { RateLimitError } from './HttpError.ts'
5 |
6 | describe('RateLimitError', () => {
7 | test('Class should set statusCode, message, and retryAfter correctly', () => {
8 | const response = new Response(null, {
9 | status: 429,
10 | headers: { 'Retry-After': '10' },
11 | })
12 | const error = new RateLimitError(response)
13 |
14 | assert.strictEqual(error.statusCode, 429)
15 | assert.strictEqual(error.message, 'Too many requests to resource, cooldown in 10 seconds.')
16 | assert.strictEqual(error.retryAfter, 10000)
17 | })
18 |
19 | test('Class should default retryAfter to 5 seconds if header is not present', () => {
20 | const response = new Response(null, {
21 | status: 429,
22 | })
23 | const error = new RateLimitError(response)
24 |
25 | assert.strictEqual(error.statusCode, 429)
26 | assert.strictEqual(error.message, 'Too many requests to resource, cooldown in 5 seconds.')
27 | assert.strictEqual(error.retryAfter, 5000)
28 | })
29 |
30 | test('RateLimitError.fromResponse should return RateLimitError if status is 429', () => {
31 | const response = new Response(null, {
32 | status: 429,
33 | headers: { 'Retry-After': '10' },
34 | })
35 | const error = RateLimitError.fromResponse(response)
36 |
37 | assert.ok(error instanceof RateLimitError)
38 | assert.strictEqual(error?.statusCode, 429)
39 | })
40 |
41 | test('RateLimitError.fromResponse should return null if status is not 429', () => {
42 | const response = new Response(null, {
43 | status: 200,
44 | })
45 | const error = RateLimitError.fromResponse(response)
46 |
47 | assert.strictEqual(error, null)
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/packages/cli/scripts/pack-sea.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # Build and pack the SEA executable for macOS arm64.
3 | set -euo pipefail
4 |
5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6 | PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
7 |
8 | SEA_CONFIG_FILE="${PROJECT_ROOT}/sea-config.json"
9 | DIST_DIR="${PROJECT_ROOT}/dist"
10 | BUNDLE="${DIST_DIR}/bundle.cjs"
11 | BLOB="${DIST_DIR}/sea-prep.blob"
12 | OUTPUT_BIN="${DIST_DIR}/nike-release-checker-macos-arm64"
13 | NODE_BIN="$(command -v node)"
14 |
15 | # Clean previous artifacts
16 | mkdir -p "${DIST_DIR}"
17 | rm -f "${BLOB}" "${OUTPUT_BIN}"
18 |
19 | echo "Building bundle to ${BUNDLE}"
20 | npm run build
21 |
22 | # Ensure the bundle exists before SEA prep
23 | if [[ ! -f "${BUNDLE}" ]]; then
24 | echo "Bundle not found at ${BUNDLE}" >&2
25 | exit 1
26 | fi
27 |
28 | echo "Generating SEA blob using config file ${SEA_CONFIG_FILE}"
29 | node --experimental-sea-config "${SEA_CONFIG_FILE}"
30 |
31 | echo "Copying Node binary ${NODE_BIN} to ${OUTPUT_BIN}"
32 | cp "${NODE_BIN}" "${OUTPUT_BIN}"
33 |
34 | # Remove old signature so injection succeeds
35 | if command -v codesign >/dev/null 2>&1; then
36 | echo "Stripping existing code signature..."
37 | codesign --remove-signature "${OUTPUT_BIN}" 2>/dev/null || true
38 | fi
39 |
40 | echo "Injecting SEA blob..."
41 | npx postject "${OUTPUT_BIN}" NODE_SEA_BLOB "${BLOB}" \
42 | --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
43 | --macho-segment-name NODE_SEA
44 |
45 | chmod +x "${OUTPUT_BIN}"
46 |
47 | # Ad-hoc sign so macOS will run the modified binary
48 | if command -v codesign >/dev/null 2>&1; then
49 | echo "Ad-hoc signing SEA binary..."
50 | codesign --sign - --force --timestamp=none "${OUTPUT_BIN}"
51 | fi
52 |
53 | echo "SEA executable ready at ${OUTPUT_BIN}"
54 |
--------------------------------------------------------------------------------
/.github/composite-actions/ensure-release-package/action.yml:
--------------------------------------------------------------------------------
1 | name: Ensure tag targets package
2 | description: Validates that the tag targets the given package using the format @nike-release-checker/@.
3 | outputs:
4 | should_skip:
5 | description: Whether downstream steps should skip because the tag is for another package.
6 | value: ${{ steps.validate.outputs.should_skip }}
7 | reason:
8 | description: A short message describing why the validation requested skipping.
9 | value: ${{ steps.validate.outputs.reason }}
10 | inputs:
11 | package:
12 | description: Package name expected in the tag (e.g. cli, sdk)
13 | required: true
14 | tag:
15 | description: Tag name to validate
16 | required: true
17 | runs:
18 | using: composite
19 | steps:
20 | - id: validate
21 | name: Validate tag matches package
22 | shell: bash
23 | run: |
24 | set -euo pipefail
25 |
26 | package="${{ inputs.package }}"
27 | tag="${{ inputs.tag }}"
28 |
29 | if [[ -z "$package" ]]; then
30 | echo "package input is required."
31 | exit 1
32 | fi
33 |
34 | if [[ -z "$tag" ]]; then
35 | echo "Could not determine tag name. Provide it via the 'tag' input."
36 | exit 1
37 | fi
38 |
39 | expected_prefix="@nike-release-checker/${package}@"
40 |
41 | if [[ "$tag" != ${expected_prefix}* ]]; then
42 | echo "::warning::Tag '${tag}' does not target package '${package}'. Expected prefix '${expected_prefix}'."
43 | echo "should_skip=true" >> "$GITHUB_OUTPUT"
44 | echo "reason=Tag '${tag}' does not target package '${package}'." >> "$GITHUB_OUTPUT"
45 | exit 0
46 | fi
47 |
48 | echo "should_skip=false" >> "$GITHUB_OUTPUT"
49 | echo "reason=" >> "$GITHUB_OUTPUT"
50 | echo "Validated: tag '${tag}' targets package '${package}'."
--------------------------------------------------------------------------------
/packages/cli/src/pages/Product/Product.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useStore } from '@nanostores/react'
3 | import { Box, Text } from 'ink'
4 |
5 | import { Image } from '../../components/Image.tsx'
6 | import { Select } from '../../components/Select/Select.tsx'
7 | import { $selectedModel, $selectedProduct, $selectedProductImage } from '../../store/product.ts'
8 | import { theme } from '../../utils/theme.ts'
9 | import { Model } from './Model/Model.tsx'
10 |
11 | export const Product = () => {
12 | const selectedProduct = useStore($selectedProduct)
13 | const selectOptions = useMemo(
14 | () =>
15 | selectedProduct?.models.map((model) => ({
16 | label: model.modelName,
17 | value: model.id,
18 | })) ?? [],
19 | [selectedProduct]
20 | )
21 |
22 | if (!selectedProduct) return No product selected
23 |
24 | return (
25 |
26 |
27 | {selectedProduct.title} (slug:{' '}
28 | {selectedProduct.slug})
29 |
30 |
31 |
32 |
40 | Select model:
41 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | const ModelImage = () => {
55 | const model = useStore($selectedProductImage)
56 |
57 | if (model?.loading)
58 | return (
59 |
60 | Image loading...
61 |
62 | )
63 |
64 | if (model?.data)
65 | return
66 | }
67 |
--------------------------------------------------------------------------------
/.github/workflows/sdk-tarball-release.yml:
--------------------------------------------------------------------------------
1 | name: Attach SDK Tarball
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 | workflow_dispatch:
8 | inputs:
9 | tag:
10 | description: "Existing tag to attach the tarball to"
11 | required: true
12 |
13 | permissions:
14 | contents: write
15 |
16 | jobs:
17 | build-and-upload:
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v6
23 | with:
24 | # use the provided tag on manual runs so gh-release sees a tag
25 | ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
26 | fetch-depth: 0
27 | fetch-tags: true
28 |
29 | - name: Validate release is SDK
30 | id: ensure_release
31 | uses: ./.github/composite-actions/ensure-release-package
32 | with:
33 | package: sdk
34 | tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
35 |
36 | - name: Install
37 | if: steps.ensure_release.outputs.should_skip != 'true'
38 | uses: ./.github/composite-actions/install
39 |
40 | - name: Pack SDK tarball
41 | id: pack
42 | if: steps.ensure_release.outputs.should_skip != 'true'
43 | run: |
44 | PACK_JSON=$(npm pack ./packages/sdk --json)
45 | echo "pack json: $PACK_JSON"
46 | TARBALL=$(node -e "const [pack] = JSON.parse(process.argv[1]); console.log(pack.filename);" "$PACK_JSON")
47 | echo "tarball_path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT"
48 |
49 | - name: Upload release asset
50 | if: steps.ensure_release.outputs.should_skip != 'true'
51 | uses: softprops/action-gh-release@v2
52 | with:
53 | tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
54 | files: ${{ steps.pack.outputs.tarball_path }}
55 |
--------------------------------------------------------------------------------
/packages/sdk/utils/Error.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { describe, test } from 'node:test'
3 |
4 | import { CustomError } from './Error.ts'
5 |
6 | describe('CustomError', () => {
7 | test('should correctly identify AbortError', () => {
8 | const abortError = new Error('Abort error')
9 | abortError.name = 'AbortError'
10 | assert.strictEqual(CustomError.isAbortError(abortError), true)
11 | assert.strictEqual(CustomError.isAbortError(new Error('Regular error')), false)
12 | })
13 |
14 | test('should correctly identify TimeoutError', () => {
15 | const timeoutError = new Error('Timeout error')
16 | timeoutError.name = 'TimeoutError'
17 | assert.strictEqual(CustomError.isTimeoutError(timeoutError), true)
18 | assert.strictEqual(CustomError.isTimeoutError(new Error('Regular error')), false)
19 | })
20 |
21 | test('should not identify non-Error objects as AbortError or TimeoutError', () => {
22 | const nonError = { name: 'AbortError' }
23 | assert.strictEqual(CustomError.isAbortError(nonError), false)
24 | assert.strictEqual(CustomError.isTimeoutError(nonError), false)
25 | })
26 |
27 | test('createAbortError should return the provided reason if given', () => {
28 | const reason = new Error('Custom abort reason')
29 | const result = CustomError.createAbortError(reason)
30 | assert.strictEqual(result, reason)
31 | })
32 |
33 | test('createAbortError should return a new DOMException if no reason is provided', () => {
34 | const result = CustomError.createAbortError()
35 | assert.strictEqual(result.name, 'AbortError')
36 | assert.strictEqual(result.message, 'This operation was aborted')
37 | assert.ok(result instanceof DOMException)
38 | })
39 |
40 | test('constructor should set correct name and message', () => {
41 | const error = new CustomError('Test error message')
42 | assert.strictEqual(error.name, 'CustomError')
43 | assert.strictEqual(error.message, 'Test error message')
44 | assert.ok(error instanceof Error)
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@nike-release-checker/cli",
3 | "description": "Simple terminal Nike stock checker (50+ countries)",
4 | "version": "0.1.0",
5 | "type": "module",
6 | "private": true,
7 | "scripts": {
8 | "start": "tsx --experimental-webstorage --localstorage-file=local.db --no-warnings src/index.tsx",
9 | "start:debug": "cross-env LOG_LEVEL=debug tsx --experimental-webstorage --localstorage-file=local.db --no-warnings src/index.tsx",
10 | "start:remote-debug": "cross-env LOG_LEVEL=debug tsx --inspect-brk --experimental-webstorage --localstorage-file=local.db --no-warnings src/devEntry.ts",
11 | "start:watch": "tsx --watch-path=./src --experimental-webstorage --localstorage-file=local.db --no-warnings src/devEntry.ts",
12 | "build": "rspack build",
13 | "build:sea": "bash ./scripts/pack-sea.sh",
14 | "build:sea:win": "pwsh -File ./scripts/pack-sea.ps1",
15 | "build:check": "node --experimental-webstorage --localstorage-file=local.db dist/bundle.cjs",
16 | "vercel:build": "node -v",
17 | "test": "node --experimental-webstorage --localstorage-file=test.db --no-warnings --test",
18 | "format": "npm run format:prettier",
19 | "format:prettier": "npm run lint:prettier -- --write",
20 | "lint": "npm run lint:prettier && npm run lint:ts",
21 | "lint:prettier": "prettier \"**/*.{json,[jt]s,[jt]sx,[cm][jt]s}\" --check",
22 | "lint:ts": "tsc --noEmit --project tsconfig.json"
23 | },
24 | "dependencies": {
25 | "@nanostores/persistent": "1.2.0",
26 | "@nanostores/react": "1.0.0",
27 | "@nanostores/router": "1.0.0",
28 | "@nike-release-checker/sdk": "*",
29 | "figures": "6.1.0",
30 | "ink": "6.5.1",
31 | "ink-select-input": "6.1.0",
32 | "ink-spinner": "5.0.0",
33 | "nanostores": "1.1.0",
34 | "pretty-bytes": "7.1.0",
35 | "react": "19.2.1",
36 | "terminal-image": "4.1.0",
37 | "winston": "3.19.0",
38 | "winston-daily-rotate-file": "5.0.0"
39 | },
40 | "devDependencies": {
41 | "@rspack/cli": "^1.6.6",
42 | "@rspack/core": "^1.6.6",
43 | "@types/react": "19.2.7",
44 | "cross-env": "10.1.0",
45 | "msw": "2.12.4",
46 | "postject": "1.0.0-alpha.6",
47 | "tsx": "4.21.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/sdk/utils/jsonRequest.ts:
--------------------------------------------------------------------------------
1 | import { delay } from './delay.ts'
2 | import { CustomError } from './Error.ts'
3 | import { HttpError, RateLimitError } from './HttpError.ts'
4 |
5 | interface RetryOptions {
6 | count: number
7 | /** ms */
8 | timeout?: number
9 | }
10 |
11 | export interface RequestOptions {
12 | url: URL
13 | method: 'GET' | 'POST'
14 | signal?: AbortSignal
15 | /** ms @default 30_000 */
16 | abortTimeout?: number
17 | retry?: RetryOptions
18 | body?: any
19 | }
20 |
21 | const DEFAULT_ABORT_TIMEOUT = 30 * 1000 // 30 seconds
22 |
23 | const handleError = async (error: Error, options: RequestOptions): Promise => {
24 | if (CustomError.isTimeoutError(error)) {
25 | console.error(
26 | `Timeout: It took more than ${DEFAULT_ABORT_TIMEOUT / 1000} seconds to get the result!`
27 | )
28 | } else if (CustomError.isAbortError(error)) {
29 | console.error('Aborted by user action')
30 | } else if (error instanceof RateLimitError) {
31 | console.error(error)
32 | await delay(error.retryAfter, { signal: options.signal })
33 | return jsonRequest(options)
34 | } else if (error instanceof HttpError) {
35 | console.error(error)
36 | if (options.retry && options.retry.count > 0) {
37 | options.retry.count--
38 | if (options.retry.timeout) await delay(options.retry.timeout, { signal: options.signal })
39 | return jsonRequest(options)
40 | }
41 | }
42 |
43 | throw error
44 | }
45 | export const jsonRequest = async (options: RequestOptions): Promise => {
46 | const { url, method, signal, abortTimeout = DEFAULT_ABORT_TIMEOUT, body } = options
47 |
48 | try {
49 | const signalTimeout = AbortSignal.timeout(abortTimeout)
50 | const combinedSignal = signal ? AbortSignal.any([signalTimeout, signal]) : signalTimeout
51 |
52 | const response = await fetch(url, {
53 | method,
54 | signal: combinedSignal,
55 | headers: { 'Content-Type': 'application/json' },
56 | body: body ? JSON.stringify(body) : undefined,
57 | })
58 |
59 | if (!response.ok) {
60 | const rateLimitError = RateLimitError.fromResponse(response)
61 | if (rateLimitError) throw rateLimitError
62 | throw new HttpError(response.status)
63 | }
64 |
65 | return response.json()
66 | } catch (error) {
67 | if (error instanceof Error) return handleError(error, options)
68 | console.error('Unknown error')
69 | throw error
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/packages/sdk/productFeed/format.ts:
--------------------------------------------------------------------------------
1 | import type { LevelOutput, ProductFeedOutput, ProductInfoOutput } from './schema.ts'
2 |
3 | interface Size {
4 | id: string
5 | gtin: string
6 | size: string
7 | level: LevelOutput
8 | }
9 |
10 | export const formatProductFeedResponse = (productsFeed: ProductFeedOutput[]) => {
11 | const initialReleases = productsFeed.map(getRelease)
12 | const potentialChildReleases: string[] = []
13 |
14 | for (const release of initialReleases) {
15 | const modelsIds = release.models.map(({ id }) => id)
16 | if (modelsIds.length > 1) {
17 | potentialChildReleases.push(...modelsIds)
18 | }
19 | }
20 |
21 | const releasesWithoutTopChilds = initialReleases.filter((release) => {
22 | const modelsIds = release.models.map(({ id }) => id)
23 | const hasManyModels = modelsIds.length > 1
24 | const isNotTopChild = !potentialChildReleases.includes(modelsIds[0])
25 | return hasManyModels || isNotTopChild
26 | })
27 |
28 | return releasesWithoutTopChilds.toSorted((a, b) => a.title.localeCompare(b.title))
29 | }
30 |
31 | const getRelease = (productFeed: ProductFeedOutput) => ({
32 | ...productFeed,
33 | slug: productFeed.publishedContent.properties.seo.slug,
34 | title: productFeed.publishedContent.properties.coverCard.properties.title,
35 | imageUrl: productFeed.publishedContent.properties.coverCard.properties.squarishURL,
36 | models: productFeed.productInfo
37 | .map(getProductModel)
38 | .toSorted(
39 | (a, b) =>
40 | getPriority(a.modelName) - getPriority(b.modelName) ||
41 | a.modelName.localeCompare(b.modelName)
42 | ),
43 | })
44 |
45 | const isChildSize = (name: string, type: 'GS' | 'PS' | 'TD') => {
46 | return new RegExp(`\\(${type}\\)|\\b${type}\\b`).test(name)
47 | }
48 | const getPriority = (name: string) => {
49 | if (isChildSize(name, 'GS')) return 1
50 | if (isChildSize(name, 'PS')) return 2
51 | if (isChildSize(name, 'TD')) return 3
52 | return 0
53 | }
54 |
55 | const getProductModel = (product: ProductInfoOutput) => ({
56 | ...product,
57 | modelName: product.merchProduct.labelName,
58 | id: product.merchProduct.id,
59 | sizes: getSizes(product),
60 | })
61 |
62 | const getSizes = ({ skus, availableGtins }: ProductInfoOutput) => {
63 | const sizes: Size[] = []
64 | for (const { gtin, nikeSize, id } of skus) {
65 | const level = availableGtins?.find((available) => available.gtin === gtin)?.level
66 | if (!level) continue
67 | sizes.push({ id, gtin, size: nikeSize, level })
68 | }
69 | return sizes
70 | }
71 |
--------------------------------------------------------------------------------
/packages/sdk/scripts/ci-check-product-feed.ts:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'node:fs'
2 | import { mkdir, readFile, writeFile } from 'node:fs/promises'
3 | import path from 'node:path'
4 |
5 | import { array, safeParse } from 'valibot'
6 |
7 | import { availableCountries } from '../models/availableCountries.ts'
8 | import { getProductFeed } from '../productFeed/api.ts'
9 | import { ProductFeedSchema } from '../productFeed/schema.ts'
10 |
11 | /**
12 | * Disclaimer:
13 | * Makes live SNKRS API requests and writes rotation state to `.gh-cache/product-feed-state.json`.
14 | * use only for CI health checks.
15 | */
16 |
17 | interface StateFile {
18 | lastIndex: number
19 | }
20 |
21 | const repoRoot = path.resolve(import.meta.dirname, '..', '..', '..')
22 | const stateDir = path.join(repoRoot, '.gh-cache')
23 | const statePath = path.join(stateDir, 'product-feed-state.json')
24 |
25 | const eligibleCountries = availableCountries.filter((country) => !country.description)
26 |
27 | if (eligibleCountries.length === 0) {
28 | throw new Error('No eligible countries found (description is empty).')
29 | }
30 |
31 | const readState = async (): Promise => {
32 | if (!existsSync(statePath)) {
33 | return { lastIndex: -1 }
34 | }
35 |
36 | try {
37 | const raw = await readFile(statePath, 'utf8')
38 | const parsed = JSON.parse(raw) as Partial
39 | if (typeof parsed.lastIndex === 'number' && parsed.lastIndex >= -1) {
40 | return { lastIndex: parsed.lastIndex }
41 | }
42 | } catch {
43 | // ignore and fall through to default
44 | }
45 |
46 | return { lastIndex: -1 }
47 | }
48 |
49 | const writeState = async (state: StateFile) => {
50 | await mkdir(stateDir, { recursive: true })
51 | await writeFile(statePath, JSON.stringify(state, null, 2), 'utf8')
52 | }
53 |
54 | const formatIssues = (issues: unknown) => JSON.stringify(issues, null, 2)
55 |
56 | const { lastIndex } = await readState()
57 | const nextIndex = (lastIndex + 1) % eligibleCountries.length
58 | const country = eligibleCountries[nextIndex]
59 |
60 | console.log(`Checking product feed for ${country.code} (${country.name})`)
61 |
62 | const response = await getProductFeed({
63 | countryCode: country.code,
64 | language: country.language,
65 | })
66 |
67 | const result = safeParse(array(ProductFeedSchema), response)
68 |
69 | if (!result.success) {
70 | const message = `ProductFeed validation failed for ${country.code} (${country.name})`
71 | const detail = formatIssues(result.issues.map(({ path: _path, ...rest }) => rest))
72 | throw new Error(`${message}\nIssues: ${detail}`)
73 | }
74 |
75 | await writeState({ lastIndex: nextIndex })
76 |
77 | console.log(`Validation passed for ${country.code} (${country.name}); next index: ${nextIndex}`)
78 |
--------------------------------------------------------------------------------
/.github/workflows/cli-sea-release.yml:
--------------------------------------------------------------------------------
1 | name: Attach CLI SEA Binaries
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 | workflow_dispatch:
8 | inputs:
9 | tag:
10 | description: 'Existing tag to attach the CLI binaries to'
11 | required: true
12 |
13 | permissions:
14 | contents: write
15 |
16 | jobs:
17 | build:
18 | name: Build CLI SEA (${{ matrix.os }})
19 | runs-on: ${{ matrix.os }}
20 | strategy:
21 | matrix:
22 | os: [macos-latest, windows-latest]
23 |
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v6
27 | with:
28 | ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
29 | fetch-depth: 0
30 | fetch-tags: true
31 |
32 | - name: Validate release is CLI
33 | id: ensure_release
34 | uses: ./.github/composite-actions/ensure-release-package
35 | with:
36 | package: cli
37 | tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
38 |
39 | - name: Install
40 | if: steps.ensure_release.outputs.should_skip != 'true'
41 | uses: ./.github/composite-actions/install
42 |
43 | - name: Build SEA (macOS)
44 | if: matrix.os == 'macos-latest' && steps.ensure_release.outputs.should_skip != 'true'
45 | working-directory: packages/cli
46 | shell: bash
47 | run: npm run build:sea
48 |
49 | - name: Build SEA (Windows)
50 | if: matrix.os == 'windows-latest' && steps.ensure_release.outputs.should_skip != 'true'
51 | working-directory: packages/cli
52 | shell: pwsh
53 | run: npm run build:sea:win
54 |
55 | - name: Upload build artifact
56 | if: steps.ensure_release.outputs.should_skip != 'true'
57 | uses: actions/upload-artifact@v4
58 | with:
59 | name: ${{ matrix.os == 'windows-latest' && 'nike-release-checker-win-x64.exe' || 'nike-release-checker-macos-arm64' }}
60 | path: ${{ matrix.os == 'windows-latest' && 'packages/cli/dist/nike-release-checker-win-x64.exe' || 'packages/cli/dist/nike-release-checker-macos-arm64' }}
61 | if-no-files-found: error
62 |
63 | upload:
64 | name: Upload release assets
65 | runs-on: ubuntu-latest
66 | needs: build
67 |
68 | steps:
69 | - name: Checkout
70 | uses: actions/checkout@v6
71 | with:
72 | ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
73 | fetch-depth: 0
74 | fetch-tags: true
75 |
76 | - name: Validate release is CLI
77 | id: ensure_release
78 | uses: ./.github/composite-actions/ensure-release-package
79 | with:
80 | package: cli
81 | tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
82 |
83 | - name: Download artifacts
84 | if: steps.ensure_release.outputs.should_skip != 'true'
85 | uses: actions/download-artifact@v4
86 | with:
87 | path: downloads
88 | merge-multiple: true
89 |
90 | - name: Upload release assets
91 | if: steps.ensure_release.outputs.should_skip != 'true'
92 | uses: softprops/action-gh-release@v2
93 | with:
94 | tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
95 | files: |
96 | downloads/nike-release-checker-macos-arm64
97 | downloads/nike-release-checker-win-x64.exe
98 |
--------------------------------------------------------------------------------
/packages/cli/src/store/country.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { beforeEach, describe, mock, test } from 'node:test'
3 |
4 | import { availableCountries } from '@nike-release-checker/sdk'
5 |
6 | import { $router } from '../router.ts'
7 | import { inputDictionary } from '../utils/inputDictionary.ts'
8 | import { createCountry } from './country.ts'
9 |
10 | const { HOME, COUNTRY } = inputDictionary
11 |
12 | describe('$country store', () => {
13 | let routerOpenMock = mock.method($router, 'open')
14 | let $country: ReturnType
15 |
16 | beforeEach(async () => {
17 | // Clear localStorage before creating store
18 | localStorage.clear()
19 | // Create a fresh store instance
20 | $country = createCountry()
21 | })
22 |
23 | test('should initialize with null value', async () => {
24 | assert.strictEqual($country.value.get(), null)
25 | assert.strictEqual($country.readableValue.get(), 'Not Selected')
26 | assert.strictEqual(routerOpenMock.mock.calls.length, 1)
27 | assert.deepStrictEqual(routerOpenMock.mock.calls[0].arguments, [COUNTRY.url])
28 | })
29 |
30 | test('should set valid country and trigger router navigation', async () => {
31 | routerOpenMock.mock.resetCalls()
32 | const targetCountry = availableCountries[0]
33 | $country.value = targetCountry.code
34 |
35 | assert.deepStrictEqual($country.value.get(), targetCountry)
36 | assert.strictEqual($country.readableValue.get(), targetCountry.name)
37 | assert.strictEqual(routerOpenMock.mock.calls.length, 1)
38 | assert.deepStrictEqual(routerOpenMock.mock.calls[0].arguments, [HOME.url])
39 | })
40 |
41 | test('should not set invalid country code', async () => {
42 | const initialState = $country.value.get()
43 | $country.value = 'INVALID' as any
44 |
45 | assert.deepStrictEqual($country.value.get(), initialState)
46 | })
47 |
48 | test('should persist country selection', async () => {
49 | const targetCountry = availableCountries[0]
50 | $country.value = targetCountry.code
51 |
52 | // Read directly from localStorage to verify persistence
53 | const stored = JSON.parse(localStorage.getItem('country') || 'null')
54 | assert.deepStrictEqual(stored, targetCountry)
55 | })
56 |
57 | test('should load persisted country on initialization', async () => {
58 | const targetCountry = availableCountries[0]
59 | localStorage.setItem('country', JSON.stringify(targetCountry))
60 |
61 | // Create new store instance that should load from localStorage
62 | routerOpenMock.mock.resetCalls()
63 | $country = createCountry()
64 |
65 | assert.deepStrictEqual($country.value.get(), targetCountry)
66 | assert.deepStrictEqual(routerOpenMock.mock.calls[0].arguments, [HOME.url])
67 | })
68 |
69 | test('should reset country and trigger router navigation', async () => {
70 | // First set a country
71 | const targetCountry = availableCountries[0]
72 | $country.value = targetCountry.code
73 |
74 | // Clear mock calls from setting the country
75 | routerOpenMock.mock.resetCalls()
76 |
77 | // Then reset it
78 | $country.reset()
79 |
80 | assert.strictEqual($country.value.get(), null)
81 | assert.strictEqual($country.readableValue.get(), 'Not Selected')
82 | assert.deepStrictEqual(routerOpenMock.mock.calls[0].arguments, [COUNTRY.url])
83 | })
84 |
85 | test('should update readableValue when country changes', async () => {
86 | const targetCountry = availableCountries[0]
87 | $country.value = targetCountry.code
88 |
89 | assert.strictEqual($country.readableValue.get(), targetCountry.name)
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/packages/sdk/utils/delay.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { afterEach, beforeEach, describe, mock, test } from 'node:test'
3 |
4 | import { delay } from './delay.ts'
5 | import { CustomError } from './Error.ts'
6 |
7 | import type { Mock } from 'node:test'
8 |
9 | describe('delay', () => {
10 | const originalSetTimeout = global.setTimeout
11 | const originalClearTimeout = global.clearTimeout
12 |
13 | let mockedSetTimeout: Mock
14 | let mockedClearTimeout: Mock
15 |
16 | beforeEach(() => {
17 | mockedSetTimeout = mock.fn(originalSetTimeout)
18 | mockedClearTimeout = mock.fn(originalClearTimeout)
19 |
20 | // Mock global methods
21 | global.setTimeout = mockedSetTimeout
22 | global.clearTimeout = mockedClearTimeout
23 | })
24 |
25 | afterEach(() => {
26 | mock.reset()
27 |
28 | // Restore original methods
29 | global.setTimeout = originalSetTimeout
30 | global.clearTimeout = originalClearTimeout
31 | })
32 |
33 | test('should call setTimeout after the specified time', async () => {
34 | const delayMs = 10000
35 | mockedSetTimeout.mock.mockImplementation(((cb: Function) => cb()) as any)
36 |
37 | await delay(delayMs)
38 |
39 | const [_, setTimeoutDelay] = mockedSetTimeout.mock.calls[0].arguments
40 | assert.strictEqual(setTimeoutDelay, delayMs)
41 | })
42 |
43 | test('should reject with AbortError if signal is aborted', async () => {
44 | const controller = new AbortController()
45 | const { signal } = controller
46 |
47 | queueMicrotask(() => controller.abort())
48 |
49 | await assert.rejects(
50 | () => delay(100, { signal }),
51 | (error: unknown) => {
52 | assert.ok(CustomError.isAbortError(error))
53 | return true
54 | }
55 | )
56 | assert.ok(mockedClearTimeout.mock.callCount(), 'should clear delay timeout when reject')
57 | })
58 |
59 | test('should remove abort event listener when successfully resolve delay', async () => {
60 | const signal = new AbortController().signal
61 | mock.method(signal, 'removeEventListener')
62 |
63 | await delay(0, { signal })
64 | assert.ok(
65 | (signal.removeEventListener as Mock).mock.callCount()
66 | )
67 | })
68 |
69 | test('should handle zero delay correctly', async () => {
70 | mockedSetTimeout.mock.mockImplementation(((cb: Function) => cb()) as any)
71 | await delay(0)
72 | assert.strictEqual(mockedSetTimeout.mock.callCount(), 1)
73 | })
74 |
75 | test('should pass negative delay to setTimeout as is', async () => {
76 | mockedSetTimeout.mock.mockImplementation(((cb: Function) => cb()) as any)
77 | await delay(-100)
78 | const [_, setTimeoutDelay] = mockedSetTimeout.mock.calls[0].arguments
79 | assert.strictEqual(setTimeoutDelay, -100)
80 | })
81 |
82 | test('should not add abort listener when signal is not provided', async () => {
83 | const signal = new AbortController().signal
84 | mock.method(signal, 'addEventListener')
85 |
86 | await delay(0)
87 | assert.strictEqual(
88 | (signal.addEventListener as Mock).mock.callCount(),
89 | 0
90 | )
91 | })
92 |
93 | test('should handle abort with custom reason', async () => {
94 | const controller = new AbortController()
95 | const { signal } = controller
96 | const customReason = new Error('Custom abort reason')
97 |
98 | queueMicrotask(() => controller.abort(customReason))
99 |
100 | await assert.rejects(
101 | () => delay(100, { signal }),
102 | (error: unknown) => error === customReason
103 | )
104 | })
105 | })
106 |
--------------------------------------------------------------------------------
/packages/sdk/models/availableCountries.ts:
--------------------------------------------------------------------------------
1 | export const availableCountries = [
2 | { code: 'AU', name: 'Australia', description: '', language: 'en-GB', emoji: '🇦🇺' },
3 | { code: 'AT', name: 'Austria', description: '', language: 'de', emoji: '🇦🇹' },
4 | { code: 'BE', name: 'Belgium', description: '', language: 'de', emoji: '🇧🇪' },
5 | { code: 'BG', name: 'Bulgaria', description: '', language: 'en-GB', emoji: '🇧🇬' },
6 | { code: 'CA', name: 'Canada', description: '', language: 'en-GB', emoji: '🇨🇦' },
7 | {
8 | code: 'CL',
9 | name: 'Chile',
10 | description: 'Not supported currently',
11 | language: 'es-419',
12 | emoji: '🇨🇱',
13 | }, // No dedicated API; only HTML pages are available. Not supported currently.
14 | { code: 'CN', name: 'China', description: '', language: 'zh-Hans', emoji: '🇨🇳' },
15 | { code: 'HR', name: 'Croatia', description: '', language: 'en-GB', emoji: '🇭🇷' },
16 | { code: 'CZ', name: 'Czechia', description: '', language: 'cs', emoji: '🇨🇿' },
17 | { code: 'DK', name: 'Denmark', description: '', language: 'da', emoji: '🇩🇰' },
18 | { code: 'EG', name: 'Egypt', description: 'No SNKRS', language: 'en-GB', emoji: '🇪🇬' },
19 | { code: 'FI', name: 'Finland', description: '', language: 'en-GB', emoji: '🇫🇮' },
20 | { code: 'FR', name: 'France', description: '', language: 'fr', emoji: '🇫🇷' },
21 | { code: 'DE', name: 'Germany', description: '', language: 'de', emoji: '🇩🇪' },
22 | { code: 'GR', name: 'Greece', description: '', language: 'el', emoji: '🇬🇷' },
23 | { code: 'HU', name: 'Hungary', description: '', language: 'en-GB', emoji: '🇭🇺' },
24 | { code: 'IN', name: 'India', description: '', language: 'en-GB', emoji: '🇮🇳' },
25 | { code: 'ID', name: 'Indonesia', description: '', language: 'en-GB', emoji: '🇮🇩' },
26 | { code: 'IE', name: 'Ireland', description: '', language: 'en-GB', emoji: '🇮🇪' },
27 | { code: 'IL', name: 'Israel', description: '', language: 'en-GB', emoji: '🇮🇱' },
28 | { code: 'IT', name: 'Italy', description: '', language: 'it', emoji: '🇮🇹' },
29 | { code: 'JP', name: 'Japan', description: '', language: 'ja', emoji: '🇯🇵' },
30 | { code: 'KR', name: 'Korea', description: '', language: 'ko', emoji: '🇰🇷' },
31 | { code: 'LU', name: 'Luxembourg', description: '', language: 'en-GB', emoji: '🇱🇺' },
32 | { code: 'MY', name: 'Malaysia', description: '', language: 'en-GB', emoji: '🇲🇾' },
33 | { code: 'MX', name: 'Mexico', description: '', language: 'es-419', emoji: '🇲🇽' },
34 | { code: 'MA', name: 'Morocco', description: 'No SNKRS', language: 'en-GB', emoji: '🇲🇦' },
35 | { code: 'NL', name: 'Netherlands', description: '', language: 'nl', emoji: '🇳🇱' },
36 | { code: 'NZ', name: 'New Zealand', description: '', language: 'en-GB', emoji: '🇳🇿' },
37 | { code: 'NO', name: 'Norway', description: '', language: 'no', emoji: '🇳🇴' },
38 | { code: 'PH', name: 'Philippines', description: '', language: 'en-GB', emoji: '🇵🇭' },
39 | { code: 'PL', name: 'Poland', description: '', language: 'pl', emoji: '🇵🇱' },
40 | { code: 'PR', name: 'Puerto Rico', description: 'No SNKRS', language: 'es-419', emoji: '🇵🇷' },
41 | { code: 'PT', name: 'Portugal', description: '', language: 'pt-PT', emoji: '🇵🇹' },
42 | { code: 'RO', name: 'Romania', description: '', language: 'en-GB', emoji: '🇷🇴' },
43 | { code: 'RU', name: 'Russia', description: 'Disabled', language: 'ru', emoji: '🇷🇺' },
44 | { code: 'SA', name: 'Saudi Arabia', description: '', language: 'en-GB', emoji: '🇸🇦' },
45 | { code: 'SG', name: 'Singapore', description: '', language: 'en-GB', emoji: '🇸🇬' },
46 | { code: 'SK', name: 'Slovakia', description: '', language: 'en-GB', emoji: '🇸🇰' },
47 | { code: 'SI', name: 'Slovenia', description: '', language: 'en-GB', emoji: '🇸🇮' },
48 | { code: 'ZA', name: 'South Africa', description: '', language: 'en-GB', emoji: '🇿🇦' },
49 | { code: 'ES', name: 'Spain', description: '', language: 'es-ES', emoji: '🇪🇸' },
50 | { code: 'SE', name: 'Sweden', description: '', language: 'sv', emoji: '🇸🇪' },
51 | { code: 'CH', name: 'Switzerland', description: '', language: 'en-GB', emoji: '🇨🇭' },
52 | { code: 'TW', name: 'Taiwan', description: '', language: 'zh-Hant', emoji: '🇹🇼' },
53 | { code: 'TH', name: 'Thailand', description: '', language: 'th', emoji: '🇹🇭' },
54 | { code: 'TR', name: 'Turkey', description: '', language: 'tr', emoji: '🇹🇷' },
55 | { code: 'AE', name: 'United Arab Emirates', description: '', language: 'en-GB', emoji: '🇦🇪' },
56 | { code: 'GB', name: 'United Kingdom', description: '', language: 'en-GB', emoji: '🇬🇧' },
57 | { code: 'US', name: 'United States', description: '', language: 'en', emoji: '🇺🇸' },
58 | { code: 'UY', name: 'Uruguay', description: '', language: 'es-419', emoji: '🇺🇾' },
59 | {
60 | code: 'VN',
61 | name: 'Vietnam',
62 | description: 'Check Thailand for SNKRS data',
63 | language: 'en-GB',
64 | emoji: '🇻🇳',
65 | }, // Looks like SNKRS redirect to Thailand
66 | ] as const satisfies AvailableCountry[]
67 |
68 | export interface AvailableCountry {
69 | code: string
70 | name: string
71 | description: string
72 | language: string
73 | emoji: string
74 | }
75 |
76 | export type CountryCode = (typeof availableCountries)[number]['code']
77 | export type CountryLanguage = (typeof availableCountries)[number]['language']
78 |
--------------------------------------------------------------------------------
/packages/sdk/utils/jsonRequest.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { afterEach, beforeEach, describe, mock, test } from 'node:test'
3 |
4 | import { CustomError } from './Error.ts'
5 | import { HttpError } from './HttpError.ts'
6 | import { jsonRequest } from './jsonRequest.ts'
7 |
8 | import type { Mock } from 'node:test'
9 | import type { RequestOptions } from './jsonRequest.ts'
10 |
11 | describe('jsonRequest', () => {
12 | const defaultOptions: RequestOptions = {
13 | url: new URL('http://example.com'),
14 | method: 'GET',
15 | }
16 |
17 | const originalConsoleError = global.console.error
18 | const originalFetch = global.fetch
19 | const originalSetTimeout = global.setTimeout
20 | const originalAbortSignal = global.AbortSignal
21 |
22 | let mockedConsoleError: Mock
23 | let mockedFetch: Mock
24 | let mockedSetTimeout: Mock
25 |
26 | beforeEach(() => {
27 | mockedConsoleError = mock.fn()
28 | mockedFetch = mock.fn(originalFetch)
29 | mockedSetTimeout = mock.fn(originalSetTimeout)
30 |
31 | // Mock global methods
32 | global.console.error = mockedConsoleError // to prevent junks in console during test
33 | global.fetch = mockedFetch
34 | global.setTimeout = mockedSetTimeout
35 | })
36 |
37 | afterEach(() => {
38 | mock.reset()
39 |
40 | // Restore original methods
41 | global.console.error = originalConsoleError
42 | global.setTimeout = originalSetTimeout
43 | global.fetch = originalFetch
44 | global.AbortSignal = originalAbortSignal
45 | })
46 |
47 | test('should successfully fetch and parse JSON', async () => {
48 | const mockResponse = { data: 'test' }
49 | mockedFetch.mock.mockImplementation(
50 | async () => new Response(JSON.stringify(mockResponse), { status: 200 })
51 | )
52 |
53 | const result = await jsonRequest(defaultOptions)
54 | assert.deepStrictEqual(result, mockResponse)
55 | })
56 |
57 | test('should throw error if response is not JSON', async () => {
58 | mockedFetch.mock.mockImplementation(async () => new Response(null, { status: 204 }))
59 |
60 | await assert.rejects(
61 | () => jsonRequest(defaultOptions),
62 | (error: unknown) => {
63 | assert.ok(error instanceof SyntaxError)
64 | return true
65 | }
66 | )
67 | })
68 |
69 | test('should handle RateLimitError', async () => {
70 | const mockedResponse = { data: 'test' }
71 | mockedFetch.mock.mockImplementation(async () => {
72 | if (mockedFetch.mock.callCount() < 1) {
73 | return new Response(null, { status: 429, headers: { 'Retry-After': '4' } })
74 | }
75 | return new Response(JSON.stringify(mockedResponse), { status: 200 })
76 | })
77 | mockedSetTimeout.mock.mockImplementation(((cb: Function) => cb()) as any)
78 |
79 | const result = await jsonRequest(defaultOptions)
80 |
81 | assert.strictEqual(mockedFetch.mock.callCount(), 2, 'fetch should be called twice')
82 | assert.deepStrictEqual(result, mockedResponse, 'should return successful response after retry')
83 | })
84 |
85 | test('should handle HttpError', async () => {
86 | mockedFetch.mock.mockImplementation(async () => new Response(null, { status: 404 }))
87 |
88 | await assert.rejects(
89 | () => jsonRequest(defaultOptions),
90 | (error: unknown) => {
91 | assert.ok(error instanceof HttpError)
92 | assert.strictEqual(error.statusCode, 404)
93 | return true
94 | }
95 | )
96 | })
97 |
98 | test('should handle AbortError', async () => {
99 | const abortController = new AbortController()
100 | const options: RequestOptions = { ...defaultOptions, signal: abortController.signal }
101 |
102 | abortController.abort()
103 |
104 | await assert.rejects(
105 | () => jsonRequest(options),
106 | (error: unknown) => {
107 | assert.ok(CustomError.isAbortError(error))
108 | return true
109 | }
110 | )
111 | })
112 |
113 | test('should handle abort TimeoutError', async () => {
114 | global.AbortSignal = { timeout: () => originalAbortSignal.timeout(0) } as any
115 |
116 | await assert.rejects(
117 | () => jsonRequest(defaultOptions),
118 | (error: unknown) => {
119 | assert.ok(CustomError.isTimeoutError(error))
120 | return true
121 | }
122 | )
123 | })
124 |
125 | test('should set default abortTimeout to 30 sec if not presented in options', async () => {
126 | const mockedAbortSignalTimeout = mock.fn(originalAbortSignal.timeout)
127 | mock.method(global.AbortSignal, 'timeout', mockedAbortSignalTimeout)
128 | mockedFetch.mock.mockImplementation(
129 | async () => new Response(JSON.stringify({ success: true }), { status: 200 })
130 | )
131 |
132 | await jsonRequest(defaultOptions)
133 |
134 | const [timeout] = mockedAbortSignalTimeout.mock.calls[0].arguments
135 | assert.strictEqual(timeout, 30_000)
136 | })
137 |
138 | test('should wait if provided timeout for retry', async () => {
139 | const mockResponse = { success: true }
140 | const retryCount = 1
141 | const retryTimeout = 5000
142 | mockedFetch.mock.mockImplementation(async () => {
143 | if (mockedFetch.mock.callCount() < retryCount) return new Response(null, { status: 500 }) // Simulate server error
144 | return new Response(JSON.stringify(mockResponse), { status: 200 })
145 | })
146 | mockedSetTimeout.mock.mockImplementation(((cb: Function) => cb()) as any)
147 |
148 | const options: RequestOptions = {
149 | ...defaultOptions,
150 | retry: { count: retryCount, timeout: retryTimeout },
151 | }
152 |
153 | const result = await jsonRequest(options)
154 |
155 | const [_cb, timeout] = mockedSetTimeout.mock.calls[0].arguments
156 |
157 | assert.strictEqual(mockedSetTimeout.mock.callCount(), retryCount)
158 | assert.strictEqual(timeout, retryTimeout)
159 | assert.strictEqual(mockedFetch.mock.callCount(), retryCount + 1)
160 | assert.deepStrictEqual(result, mockResponse, 'should return successful response after retry')
161 | })
162 |
163 | test('should retry on error if retry option is set', async () => {
164 | const mockResponse = { success: true }
165 | const retryCount = 2
166 |
167 | mockedFetch.mock.mockImplementation(async () => {
168 | if (mockedFetch.mock.callCount() < retryCount) return new Response(null, { status: 404 })
169 | return new Response(JSON.stringify(mockResponse), { status: 200 })
170 | })
171 |
172 | const options: RequestOptions = { ...defaultOptions, retry: { count: retryCount, timeout: 0 } }
173 | const result = await jsonRequest(options)
174 |
175 | assert.deepStrictEqual(result, mockResponse)
176 | assert.strictEqual(mockedFetch.mock.callCount(), retryCount + 1)
177 | })
178 |
179 | test('should not retry on error if retry option is set to 0', async () => {
180 | mockedFetch.mock.mockImplementation(async () => new Response(null, { status: 404 }))
181 |
182 | const options: RequestOptions = { ...defaultOptions, retry: { count: 0, timeout: 0 } }
183 |
184 | await assert.rejects(() => jsonRequest(options))
185 | assert.strictEqual(mockedFetch.mock.callCount(), 1)
186 | })
187 |
188 | test('should send POST request with body', async () => {
189 | const mockResponse = { success: true }
190 | const requestBody = { key: 'value' }
191 |
192 | mockedFetch.mock.mockImplementation(
193 | async () => new Response(JSON.stringify(mockResponse), { status: 200 })
194 | )
195 |
196 | const options: RequestOptions = { ...defaultOptions, method: 'POST', body: requestBody }
197 |
198 | await jsonRequest(options)
199 |
200 | const [_url, option] = mockedFetch.mock.calls[0].arguments
201 | const receivedBody = JSON.parse(option?.body as string)
202 | await assert.deepStrictEqual(receivedBody, requestBody)
203 | })
204 | })
205 |
--------------------------------------------------------------------------------
/packages/cli/src/store/product.ts:
--------------------------------------------------------------------------------
1 | import { formatProductFeedResponse, getProductFeed } from '@nike-release-checker/sdk'
2 | import { atom, computed, map, onMount, task } from 'nanostores'
3 | import prettyBytes from 'pretty-bytes'
4 |
5 | import { $router } from '../router.ts'
6 | import { inputDictionary } from '../utils/inputDictionary.ts'
7 | import { logger } from '../utils/logger.ts'
8 | import { $country } from './country.ts'
9 |
10 | type FormatProductFeedResponse = ReturnType
11 |
12 | interface ProductState {
13 | loading: boolean
14 | error: string | null
15 | data: FormatProductFeedResponse | null
16 | }
17 |
18 | const createProducts = () => {
19 | const LOG_SCOPE = 'products'
20 | const initialState: ProductState = {
21 | loading: true,
22 | error: null,
23 | data: [],
24 | }
25 |
26 | const $store = map(initialState)
27 | let lastLoadedCountryKey: string | null = null
28 |
29 | onMount($store, () =>
30 | $country.value.subscribe((country) => {
31 | if (!country) {
32 | logger.debug('country not selected, skip product load', { scope: LOG_SCOPE })
33 | return
34 | }
35 |
36 | const { code: countryCode, language } = country
37 | const countryKey = `${countryCode}:${language}`
38 | const state = $store.get()
39 |
40 | if (countryKey === lastLoadedCountryKey && state.data?.length && !state.error) {
41 | logger.debug('skip reload for same country with cached data', {
42 | scope: LOG_SCOPE,
43 | countryKey,
44 | itemCount: state.data.length,
45 | })
46 | return
47 | }
48 |
49 | lastLoadedCountryKey = countryKey
50 | $store.set(initialState)
51 | logger.debug('product load started', { scope: LOG_SCOPE, countryKey })
52 |
53 | task(async () => {
54 | try {
55 | const data = formatProductFeedResponse(await getProductFeed({ countryCode, language }))
56 | $store.setKey('data', data)
57 | logger.info('product load succeeded', {
58 | scope: LOG_SCOPE,
59 | countryKey,
60 | itemCount: data.length,
61 | })
62 | } catch (error) {
63 | const errorMsg = error instanceof Error ? error.message : 'Some error ocured'
64 | $store.setKey('error', errorMsg)
65 | logger.error('product load failed', {
66 | scope: LOG_SCOPE,
67 | countryKey,
68 | error: errorMsg,
69 | })
70 | } finally {
71 | $store.setKey('loading', false)
72 | logger.debug('product load finished', { scope: LOG_SCOPE, countryKey })
73 | }
74 | })
75 | })
76 | )
77 |
78 | return {
79 | get value() {
80 | return $store
81 | },
82 | }
83 | }
84 |
85 | const createSelectedProductSlug = () => {
86 | const LOG_SCOPE = 'selected-product-slug'
87 | const $store = atom(null)
88 |
89 | const reset = () => {
90 | logger.info('product slug cleared', { scope: LOG_SCOPE })
91 | $store.set(null)
92 | }
93 |
94 | $store.listen((selectedProduct) => {
95 | if (selectedProduct) {
96 | $router.open(inputDictionary.PRODUCT.routeName)
97 | }
98 | })
99 |
100 | $router.listen((routerState) => {
101 | if (routerState?.route !== inputDictionary.PRODUCT.routeName) {
102 | reset()
103 | }
104 | })
105 |
106 | return {
107 | get value(): typeof $store {
108 | return $store
109 | },
110 | set value(slug: string | null) {
111 | logger.info('product slug set', { scope: LOG_SCOPE, slug })
112 | $store.set(slug)
113 | },
114 | reset,
115 | }
116 | }
117 |
118 | export const $products = createProducts()
119 | export const $selectedProductSlug = createSelectedProductSlug()
120 | export const $selectedProduct = computed($selectedProductSlug.value, (selectedProductSlug) => {
121 | const product =
122 | $products.value.get().data?.find(({ slug }) => slug === selectedProductSlug) ?? null
123 |
124 | logger.debug('selected product resolved', {
125 | scope: 'selected-product',
126 | slug: selectedProductSlug,
127 | })
128 |
129 | return product
130 | })
131 |
132 | const createSelectedModel = () => {
133 | const LOG_SCOPE = 'selected-model'
134 | const $selectedModelIdAtom = atom(null)
135 |
136 | const reset = () => {
137 | logger.info('model cleared', { scope: LOG_SCOPE })
138 | $selectedModelIdAtom.set(null)
139 | }
140 |
141 | $selectedProductSlug.value.listen(() => {
142 | logger.debug('selected product changed, resetting model', { scope: LOG_SCOPE })
143 | reset()
144 | })
145 |
146 | return {
147 | value: computed(
148 | [$selectedProduct, $selectedModelIdAtom],
149 | (selectedProduct, selectedModelId) => {
150 | if (!selectedProduct) {
151 | logger.debug('no selected product', { scope: LOG_SCOPE })
152 | return null
153 | }
154 |
155 | if (!selectedModelId) {
156 | const fallbackModel = selectedProduct.models[0] ?? null
157 | logger.debug('no model selected, trying to use first available model', {
158 | scope: LOG_SCOPE,
159 | slug: selectedProduct.slug,
160 | modelId: fallbackModel?.id,
161 | })
162 | return fallbackModel
163 | }
164 |
165 | const foundModel =
166 | selectedProduct.models.find((model) => model.id === selectedModelId) ?? null
167 | logger.debug('selected model resolved to ' + (foundModel?.modelName ?? foundModel?.id), {
168 | scope: LOG_SCOPE,
169 | slug: selectedProduct.slug,
170 | modelId: foundModel?.id,
171 | })
172 |
173 | return foundModel
174 | }
175 | ),
176 | setId: (modelId: string) => {
177 | logger.info('model selected', { scope: LOG_SCOPE, modelId })
178 | $selectedModelIdAtom.set(modelId)
179 | },
180 | }
181 | }
182 | export const $selectedModel = createSelectedModel()
183 |
184 | const createProductImageStore = () => {
185 | const LOG_SCOPE = 'product-image'
186 | const store = map>()
187 | const getTotalStoreBufferSize = () =>
188 | Object.values(store.get()).reduce((acc, { data }) => acc + (data?.byteLength ?? 0), 0)
189 |
190 | const uniqId = crypto.randomUUID()
191 | logger.debug('product image store created', { scope: LOG_SCOPE, uniqId })
192 | onMount(store, () => {
193 | logger.debug('product image store mounted', { scope: LOG_SCOPE, uniqId })
194 | return $selectedProduct.subscribe((selectedProduct) => {
195 | const slug = selectedProduct?.slug
196 | if (!slug) {
197 | logger.debug('skip image fetch, no product selected', { scope: LOG_SCOPE })
198 | return
199 | }
200 | if (store.get()[slug]) {
201 | logger.debug('image already cached, skipping fetch', { scope: LOG_SCOPE, uniqId, slug })
202 | return
203 | }
204 | task(async () => {
205 | try {
206 | logger.debug('product image fetch started', { scope: LOG_SCOPE, slug })
207 | store.setKey(slug, { loading: true, data: null })
208 | const response = await fetch(selectedProduct.imageUrl)
209 | if (!response.ok) return
210 | const arrayBuffer = await response.arrayBuffer()
211 | store.setKey(slug, { data: arrayBuffer, loading: false })
212 | logger.debug('product image fetch succeeded', {
213 | scope: LOG_SCOPE,
214 | slug,
215 | imageSize: prettyBytes(arrayBuffer.byteLength),
216 | totalSize: prettyBytes(getTotalStoreBufferSize()),
217 | })
218 | logger.info('product image fetch succeeded', { scope: LOG_SCOPE, slug })
219 | } catch (error) {
220 | const errorMsg = error instanceof Error ? error.message : 'unknown error'
221 | logger.error('product image fetch errored', {
222 | scope: LOG_SCOPE,
223 | slug,
224 | error: errorMsg,
225 | })
226 | store.setKey(slug, { data: null, loading: false })
227 | }
228 | })
229 | })
230 | })
231 |
232 | return store
233 | }
234 | const $productImageStore = createProductImageStore()
235 | export const $selectedProductImage = computed(
236 | [$selectedProduct, $productImageStore],
237 | (selectedProduct, productImageStore) => {
238 | const LOG_SCOPE = 'selected-product-image'
239 | if (!selectedProduct) {
240 | logger.debug('no product selected, image unavailable', { scope: LOG_SCOPE })
241 | return
242 | }
243 | return productImageStore[selectedProduct.slug]
244 | }
245 | )
246 |
--------------------------------------------------------------------------------
/packages/sdk/productFeed/schema.ts:
--------------------------------------------------------------------------------
1 | import * as v from 'valibot'
2 |
3 | import { createSnkrsRootResponseSchema } from '../models/snkrsRootResponse.ts'
4 |
5 | export const MarketplaceSchema = v.union([
6 | v.literal('AE'),
7 | v.literal('ASTLA'),
8 | v.literal('AT'),
9 | v.literal('AU'),
10 | v.literal('BE'),
11 | v.literal('BG'),
12 | v.literal('CA'),
13 | v.literal('CH'),
14 | v.literal('CL'),
15 | v.literal('CN'),
16 | v.literal('CAN'),
17 | v.literal('CZ'),
18 | v.literal('DE'),
19 | v.literal('DK'),
20 | v.literal('EG'),
21 | v.literal('ES'),
22 | v.literal('FI'),
23 | v.literal('FR'),
24 | v.literal('GB'),
25 | v.literal('GR'),
26 | v.literal('HR'),
27 | v.literal('HU'),
28 | v.literal('ID'),
29 | v.literal('IE'),
30 | v.literal('IL'),
31 | v.literal('IN'),
32 | v.literal('IT'),
33 | v.literal('JP'),
34 | v.literal('KR'),
35 | v.literal('KW'),
36 | v.literal('LU'),
37 | v.literal('MA'),
38 | v.literal('MX'),
39 | v.literal('MY'),
40 | v.literal('NL'),
41 | v.literal('NO'),
42 | v.literal('NZ'),
43 | v.literal('PH'),
44 | v.literal('PL'),
45 | v.literal('PR'),
46 | v.literal('PT'),
47 | v.literal('QA'),
48 | v.literal('RO'),
49 | v.literal('RU'),
50 | v.literal('SA'),
51 | v.literal('SE'),
52 | v.literal('SG'),
53 | v.literal('SI'),
54 | v.literal('SK'),
55 | v.literal('TH'),
56 | v.literal('TR'),
57 | v.literal('TW'),
58 | v.literal('US'),
59 | v.literal('VN'),
60 | v.literal('ZA'),
61 | ])
62 |
63 | export const Collectionsv2Schema = v.object({
64 | collectionTermIds: v.array(v.unknown()),
65 | groupedCollectionTermIds: v.unknown(),
66 | })
67 |
68 | export const SelfSchema = v.object({
69 | ref: v.string(),
70 | })
71 |
72 | export const ObjectLinksSchema = v.object({
73 | self: SelfSchema,
74 | })
75 |
76 | export const AvailabilitySchema = v.object({
77 | available: v.boolean(),
78 | id: v.string(),
79 | productId: v.string(),
80 | resourceType: v.literal('availableProducts'),
81 | })
82 |
83 | export const MerchGroupSchema = v.union([
84 | v.literal('CN'),
85 | v.literal('EU'),
86 | v.literal('JP'),
87 | v.literal('MX'),
88 | v.literal('US'),
89 | v.literal('XA'),
90 | v.literal('XP'),
91 | v.literal('KR'),
92 | ])
93 |
94 | export const LevelSchema = v.union([
95 | v.literal('HIGH'),
96 | v.literal('LOW'),
97 | v.literal('MEDIUM'),
98 | v.literal('OOS'),
99 | v.literal('NA'),
100 | ])
101 |
102 | export const LocationIDSchema = v.object({
103 | id: MerchGroupSchema,
104 | type: v.literal('merchGroup'),
105 | })
106 |
107 | export const AvailableGtinSchema = v.object({
108 | available: v.boolean(),
109 | gtin: v.string(),
110 | level: LevelSchema,
111 | locationId: LocationIDSchema,
112 | method: v.literal('SHIP'),
113 | styleColor: v.string(),
114 | styleType: v.literal('INLINE'),
115 | })
116 |
117 | export const FastAvailabilitySchema = v.object({
118 | skus: v.array(v.string()),
119 | })
120 |
121 | export const LaunchViewMethodSchema = v.union([v.literal('DAN'), v.literal('LEO')])
122 |
123 | export const LaunchViewSchema = v.object({
124 | id: v.string(),
125 | links: ObjectLinksSchema,
126 | method: LaunchViewMethodSchema,
127 | paymentMethod: v.literal('PREPAY'),
128 | productId: v.string(),
129 | resourceType: v.literal('launchview'),
130 | startEntryDate: v.string(),
131 | stopEntryDate: v.optional(v.string()),
132 | })
133 |
134 | export const MerchPriceSchema = v.object({
135 | country: MarketplaceSchema,
136 | currency: v.string(),
137 | currentPrice: v.number(),
138 | discounted: v.boolean(),
139 | fullPrice: v.number(),
140 | id: v.string(),
141 | links: ObjectLinksSchema,
142 | modificationDate: v.string(),
143 | msrp: v.optional(v.number()),
144 | parentId: v.string(),
145 | parentType: v.literal('merchProduct'),
146 | productId: v.string(),
147 | promoExclusions: v.array(v.string()),
148 | promoInclusions: v.array(v.string()),
149 | resourceType: v.literal('merchPrice'),
150 | snapshotId: v.string(),
151 | })
152 |
153 | export const PromoExclusionSchema = v.union([v.literal('FALSE'), v.literal('TRUE')])
154 |
155 | export const BrandSchema = v.union([v.literal('Jordan'), v.literal('Nike'), v.literal('Converse')])
156 |
157 | export const ChannelSchema = v.union([
158 | v.literal('Nike Store Experiences'),
159 | v.literal('Nike.com'),
160 | v.literal('NikeApp'),
161 | v.literal('SNKRS'),
162 | v.literal('WeChat'),
163 | ])
164 |
165 | export const ConsumerChannelSchema = v.object({
166 | id: v.string(),
167 | resourceType: v.literal('globalization/consumer_channels'),
168 | })
169 |
170 | export const CustomizationSchema = v.object({
171 | nikeIdStyleCode: v.string(),
172 | })
173 |
174 | export const GenderSchema = v.union([
175 | v.literal('BOYS'),
176 | v.literal('GIRLS'),
177 | v.literal('MEN'),
178 | v.literal('WOMEN'),
179 | ])
180 |
181 | export const LimitRetailExperienceValueSchema = v.union([
182 | v.literal('Nike App Self-Checkout'),
183 | v.literal('Scan to Learn'),
184 | v.literal('Scan to Try'),
185 | ])
186 |
187 | export const LimitRetailExperienceSchema = v.object({
188 | disabledStoreOfferingCodes: v.array(v.string()),
189 | value: LimitRetailExperienceValueSchema,
190 | })
191 |
192 | export const ProductRollupSchema = v.object({
193 | key: v.string(),
194 | type: v.literal('Standard'),
195 | })
196 |
197 | export const ProductTypeSchema = v.union([
198 | v.literal('APPAREL'),
199 | v.literal('FOOTWEAR'),
200 | v.literal('EQUIPMENT'),
201 | ])
202 |
203 | export const PublishTypeSchema = v.union([v.literal('FLOW'), v.literal('LAUNCH')])
204 |
205 | export const SportTagSchema = v.union([
206 | v.literal('Basketball'),
207 | v.literal('Dance'),
208 | v.literal('Lifestyle'),
209 | v.literal('Skateboarding'),
210 | v.literal('Soccer'),
211 | v.literal('Tennis'),
212 | v.literal('Outdoor'),
213 | v.literal('Running'),
214 | v.literal('Lacrosse'),
215 | v.literal('Training & Gym'),
216 | v.literal('Baseball'),
217 | v.literal('Workouts'),
218 | v.literal('Football'),
219 | ])
220 |
221 | export const StatusSchema = v.union([v.literal('ACTIVE'), v.literal('HOLD'), v.literal('INACTIVE')])
222 |
223 | export const TaxonomyAttributeSchema = v.object({
224 | ids: v.array(v.string()),
225 | resourceType: v.literal('merch/taxonomy_attributes'),
226 | })
227 |
228 | export const VasTypeSchema = v.union([v.literal('GIFT_MESSAGE'), v.literal('GIFT_WRAP')])
229 |
230 | export const ValueAddedServiceSchema = v.object({
231 | id: v.string(),
232 | vasType: VasTypeSchema,
233 | })
234 |
235 | export const MerchProductSchema = v.object({
236 | abTestValues: v.array(v.unknown()),
237 | brand: BrandSchema,
238 | catalogId: v.string(),
239 | channels: v.array(ChannelSchema),
240 | classificationConcepts: v.array(v.unknown()),
241 | colorCode: v.string(),
242 | comingSoonCountdownClock: v.optional(v.boolean()),
243 | commerceCountryExclusions: v.array(MarketplaceSchema),
244 | commerceCountryInclusions: v.array(v.unknown()),
245 | commerceEndDate: v.optional(v.string()),
246 | commercePublishDate: v.optional(v.string()),
247 | commerceStartDate: v.string(),
248 | consumerChannels: v.array(ConsumerChannelSchema),
249 | customization: v.optional(CustomizationSchema),
250 | exclusiveAccess: v.boolean(),
251 | genders: v.array(GenderSchema),
252 | hardLaunch: v.boolean(),
253 | hideFromCSR: v.boolean(),
254 | hideFromSearch: v.boolean(),
255 | hidePayment: v.boolean(),
256 | id: v.string(),
257 | inventoryOverride: v.boolean(),
258 | inventoryShareOff: v.boolean(),
259 | isAppleWatch: v.boolean(),
260 | isAttributionApproved: v.boolean(),
261 | isCopyAvailable: v.optional(v.boolean()),
262 | isCustomsApproved: v.boolean(),
263 | isImageAvailable: v.optional(v.boolean()),
264 | isPromoExclusionMessage: v.boolean(),
265 | labelName: v.string(),
266 | legacyCatalogIds: v.array(v.unknown()),
267 | limitRetailExperience: v.array(LimitRetailExperienceSchema),
268 | links: ObjectLinksSchema,
269 | mainColor: v.boolean(),
270 | merchGroup: MerchGroupSchema,
271 | modificationDate: v.string(),
272 | nikeIdStyleCode: v.optional(v.string()),
273 | notifyMeIndicator: v.boolean(),
274 | pid: v.string(),
275 | preOrder: v.boolean(),
276 | productGroupId: v.optional(v.string()),
277 | productRollup: ProductRollupSchema,
278 | productType: ProductTypeSchema,
279 | publishType: PublishTypeSchema,
280 | quantityLimit: v.number(),
281 | resourceType: v.literal('merchProduct'),
282 | sizeConverterId: v.string(),
283 | sizeGuideId: v.optional(v.string()),
284 | snapshotId: v.string(),
285 | softLaunchDate: v.optional(v.string()),
286 | sportTags: v.array(SportTagSchema),
287 | status: StatusSchema,
288 | styleCode: v.string(),
289 | styleColor: v.string(),
290 | styleType: v.literal('INLINE'),
291 | taxonomyAttributes: v.array(TaxonomyAttributeSchema),
292 | valueAddedServices: v.array(ValueAddedServiceSchema),
293 | })
294 |
295 | export const AthleteSchema = v.object({
296 | localizedValue: v.string(),
297 | })
298 |
299 | export const ColorTypeSchema = v.union([
300 | v.literal('LOGO'),
301 | v.literal('PRIMARY'),
302 | v.literal('SECONDARY'),
303 | v.literal('SIMPLE'),
304 | v.literal('TERTIARY'),
305 | ])
306 |
307 | export const ColorSchema = v.object({
308 | hex: v.string(),
309 | name: v.string(),
310 | type: ColorTypeSchema,
311 | })
312 |
313 | export const LangLocaleSchema = v.union([
314 | v.literal('cs_CZ'),
315 | v.literal('da_DK'),
316 | v.literal('de_DE'),
317 | v.literal('el_GR'),
318 | v.literal('en_GB'),
319 | v.literal('en_US'),
320 | v.literal('es_ES'),
321 | v.literal('es_LA'),
322 | v.literal('fr_FR'),
323 | v.literal('it_IT'),
324 | v.literal('ja_JP'),
325 | v.literal('ko_KR'),
326 | v.literal('nl_NL'),
327 | v.literal('no_NO'),
328 | v.literal('pl_PL'),
329 | v.literal('pt_PT'),
330 | v.literal('sv_SE'),
331 | v.literal('th_TH'),
332 | v.literal('tr_TR'),
333 | v.literal('zh_CN'),
334 | v.literal('zh_TW'),
335 | ])
336 |
337 | export const WidthSchema = v.object({
338 | localizedValue: v.string(),
339 | value: v.literal('REGULAR'),
340 | })
341 |
342 | export const ProductContentSchema = v.object({
343 | athletes: v.array(AthleteSchema),
344 | bestFor: v.array(v.unknown()),
345 | colorDescription: v.string(),
346 | colors: v.array(ColorSchema),
347 | description: v.optional(v.string()),
348 | descriptionHeading: v.optional(v.string()),
349 | fullTitle: v.optional(v.string()),
350 | globalPid: v.string(),
351 | langLocale: LangLocaleSchema,
352 | manufacturingCountriesOfOrigin: v.array(v.string()),
353 | slug: v.optional(v.string()),
354 | subtitle: v.optional(v.string()),
355 | techSpec: v.string(),
356 | title: v.optional(v.string()),
357 | widths: v.array(WidthSchema),
358 | })
359 |
360 | export const CountrySpecificationSchema = v.object({
361 | country: MarketplaceSchema,
362 | localizedSize: v.string(),
363 | localizedSizePrefix: v.optional(
364 | v.union([v.literal('CM'), v.literal('EU'), v.literal('JP'), v.literal('UK'), v.literal('US')])
365 | ),
366 | taxInfo: v.object({
367 | commodityCode: v.optional(v.string()),
368 | }),
369 | })
370 |
371 | export const SkusSchema = v.object({
372 | catalogSkuId: v.string(),
373 | countrySpecifications: v.array(CountrySpecificationSchema),
374 | gtin: v.string(),
375 | id: v.string(),
376 | links: ObjectLinksSchema,
377 | merchGroup: MerchGroupSchema,
378 | modificationDate: v.string(),
379 | nikeSize: v.string(),
380 | parentId: v.string(),
381 | parentType: v.literal('merchProduct'),
382 | productId: v.string(),
383 | resourceType: v.literal('merchSku'),
384 | sizeConversionId: v.optional(v.string()),
385 | snapshotId: v.string(),
386 | stockKeepingUnitId: v.string(),
387 | vatCode: v.optional(v.string()),
388 | })
389 |
390 | export const TaxInfoSchema = v.object({
391 | commodityCode: v.optional(v.string()),
392 | })
393 |
394 | export const SocialInterestSchema = v.object({
395 | id: v.string(),
396 | })
397 |
398 | export const FastProductInfoSchema = v.object({
399 | availability: AvailabilitySchema,
400 | availableGtins: v.optional(v.array(AvailableGtinSchema)),
401 | fastAvailability: v.optional(FastAvailabilitySchema),
402 | launchView: v.optional(LaunchViewSchema),
403 | merchPrice: MerchPriceSchema,
404 | merchProduct: MerchProductSchema,
405 | productContent: ProductContentSchema,
406 | skus: v.array(SkusSchema),
407 | socialInterest: SocialInterestSchema,
408 | })
409 |
410 | export const AnalyticsSchema = v.object({
411 | hashKey: v.string(),
412 | })
413 |
414 | export const ExternalReferenceSchema = v.object({
415 | domain: v.literal('control_plane'),
416 | id: v.string(),
417 | resource: v.literal('execution'),
418 | })
419 |
420 | export const PublishedContentLinksSchema = v.object({
421 | self: v.string(),
422 | })
423 |
424 | export const PortraitSchema = v.object({
425 | startImageUrl: v.optional(v.string()),
426 | assetId: v.optional(v.string()),
427 | manifestURL: v.optional(v.string()),
428 | providerId: v.optional(v.string()),
429 | videoId: v.optional(v.string()),
430 | })
431 |
432 | export const LandscapeTypeSchema = v.union([v.literal('editorial'), v.literal('product')])
433 |
434 | export const LandscapeSchema = v.object({
435 | aspectRatio: v.optional(v.number()),
436 | id: v.string(),
437 | type: v.optional(LandscapeTypeSchema),
438 | url: v.optional(v.string()),
439 | view: v.optional(v.string()),
440 | })
441 |
442 | export const SecondaryPortraitSchema = v.object({
443 | view: v.optional(v.string()),
444 | id: v.optional(v.string()),
445 | url: v.optional(v.string()),
446 | aspectRatio: v.optional(v.number()),
447 | type: v.optional(LandscapeTypeSchema),
448 | })
449 |
450 | export const StylePropertiesSchema = v.object({
451 | actions: v.unknown(),
452 | })
453 |
454 | export const StyleSchema = v.object({
455 | defaultStyle: v.unknown(),
456 | exposeTemplate: v.boolean(),
457 | modifiedDate: v.string(),
458 | properties: StylePropertiesSchema,
459 | resourceType: v.literal('content/style'),
460 | })
461 |
462 | export const ContainerTypeEnumSchema = v.union([
463 | v.literal('carousel'),
464 | v.literal('image'),
465 | v.literal('text'),
466 | v.literal('video'),
467 | v.literal('story_format'),
468 | ])
469 |
470 | export const CoverCardPropertiesSchema = v.object({
471 | actions: v.array(v.unknown()),
472 | altText: v.string(),
473 | body: v.optional(v.string()),
474 | colorTheme: v.union([v.literal('dark'), v.literal('light')]),
475 | copyId: v.string(),
476 | custom: v.unknown(),
477 | fallbacks: v.optional(v.array(v.unknown())),
478 | internalName: v.optional(v.string()),
479 | imageCaption: v.optional(v.string()),
480 | landscape: LandscapeSchema,
481 | landscapeId: v.string(),
482 | landscapeURL: v.string(),
483 | portrait: LandscapeSchema,
484 | portraitId: v.string(),
485 | portraitURL: v.string(),
486 | product: v.array(v.unknown()),
487 | richTextLinks: v.array(v.unknown()),
488 | secondaryPortrait: v.optional(LandscapeSchema),
489 | squarish: LandscapeSchema,
490 | squarishId: v.string(),
491 | squarishURL: v.string(),
492 | style: StyleSchema,
493 | subtitle: v.string(),
494 | title: v.string(),
495 | })
496 |
497 | export const CoverCardSchema = v.object({
498 | analytics: AnalyticsSchema,
499 | id: v.string(),
500 | properties: CoverCardPropertiesSchema,
501 | subType: ContainerTypeEnumSchema,
502 | type: v.literal('card'),
503 | version: v.string(),
504 | })
505 |
506 | export const CustomSchema = v.object({
507 | hideFromStock: v.optional(v.array(v.unknown())),
508 | hideFromUpcoming: v.optional(
509 | v.array(v.object({ productId: v.string(), styleColor: v.string() }))
510 | ),
511 | restricted: v.optional(v.boolean()),
512 | tags: v.optional(v.array(v.string())),
513 | })
514 |
515 | export const MetadataDecorationPayloadSchema = v.object({
516 | hideFeedCard: v.optional(v.boolean()),
517 | previewTitleOverride: v.optional(v.string()),
518 | })
519 |
520 | export const MetadataDecorationSchema = v.object({
521 | id: v.string(),
522 | namespace: v.string(),
523 | payload: MetadataDecorationPayloadSchema,
524 | })
525 |
526 | export const ProductSchema = v.object({
527 | productId: v.string(),
528 | styleColor: v.string(),
529 | })
530 |
531 | export const PublishSchema = v.object({
532 | collectionGroups: v.array(v.string()),
533 | collections: v.array(v.string()),
534 | countries: v.array(MarketplaceSchema),
535 | pageId: v.optional(v.string()),
536 | })
537 |
538 | export const SEOSchema = v.object({
539 | description: v.string(),
540 | doNotIndex: v.boolean(),
541 | keywords: v.string(),
542 | slug: v.string(),
543 | title: v.string(),
544 | })
545 |
546 | export const SocialSchema = v.object({
547 | comments: v.boolean(),
548 | likes: v.boolean(),
549 | share: v.boolean(),
550 | })
551 |
552 | export const PublishedContentThreadTypeSchema = v.union([
553 | v.literal('multi_product'),
554 | v.literal('product'),
555 | ])
556 |
557 | export const PublishedContentPropertiesSchema = v.object({
558 | coverCard: CoverCardSchema,
559 | custom: CustomSchema,
560 | metadataDecorations: v.optional(v.array(MetadataDecorationSchema)),
561 | products: v.array(ProductSchema),
562 | publish: PublishSchema,
563 | relatedThreads: v.optional(v.array(v.string())),
564 | seo: SEOSchema,
565 | social: v.optional(SocialSchema),
566 | subtitle: v.optional(v.string()),
567 | threadType: PublishedContentThreadTypeSchema,
568 | title: v.optional(v.string()),
569 | })
570 |
571 | export const RichTextLinkSchema = v.object({
572 | id: v.string(),
573 | type: v.literal('URL'),
574 | url: v.string(),
575 | })
576 |
577 | export const AttrsSchema = v.object({
578 | 'data-id': v.optional(v.string()),
579 | href: v.string(),
580 | target: v.literal('_blank'),
581 | })
582 |
583 | export const MarkTypeSchema = v.union([
584 | v.literal('link'),
585 | v.literal('underline'),
586 | v.literal('strong'),
587 | ])
588 |
589 | export const MarkSchema = v.object({
590 | attrs: v.optional(AttrsSchema),
591 | type: MarkTypeSchema,
592 | })
593 |
594 | export const ContentInnerContentSchema = v.object({
595 | marks: v.optional(v.array(MarkSchema)),
596 | text: v.string(),
597 | type: ContainerTypeEnumSchema,
598 | })
599 |
600 | export const JSONBodyContentSchema = v.object({
601 | content: v.array(ContentInnerContentSchema),
602 | type: v.literal('paragraph'),
603 | })
604 |
605 | export const JSONBodySchema = v.object({
606 | content: v.array(JSONBodyContentSchema),
607 | type: v.literal('doc'),
608 | })
609 |
610 | export const DestinationSchema = v.object({
611 | product: ProductSchema,
612 | type: v.union([v.literal('BUYING_TOOLS'), v.literal('THREAD_ID')]),
613 | })
614 |
615 | export const ActionSchema = v.object({
616 | actionType: v.union([v.literal('cta_buying_tools'), v.literal('minicard_link')]),
617 | analytics: AnalyticsSchema,
618 | destination: DestinationSchema,
619 | destinationId: v.optional(v.string()),
620 | id: v.string(),
621 | product: v.optional(ProductSchema),
622 | })
623 |
624 | export const PublishedContentNodePropertiesSchema = v.object({
625 | actions: v.array(ActionSchema),
626 | altText: v.optional(v.string()),
627 | autoPlay: v.optional(v.boolean()),
628 | body: v.optional(v.string()),
629 | colorTheme: v.literal('dark'),
630 | containerType: v.optional(ContainerTypeEnumSchema),
631 | copyId: v.string(),
632 | custom: v.optional(v.unknown()),
633 | fallbacks: v.optional(v.array(v.unknown())),
634 | imageCaption: v.optional(v.string()),
635 | internalName: v.optional(v.string()),
636 | jsonBody: v.optional(JSONBodySchema),
637 | landscape: v.optional(LandscapeSchema),
638 | landscapeId: v.optional(v.string()),
639 | landscapeURL: v.optional(v.string()),
640 | loop: v.optional(v.boolean()),
641 | portrait: v.optional(v.union([LandscapeSchema, PortraitSchema])),
642 | portraitId: v.optional(v.string()),
643 | portraitURL: v.optional(v.string()),
644 | product: v.optional(v.array(v.unknown())),
645 | richTextLinks: v.array(RichTextLinkSchema),
646 | secondaryPortrait: v.optional(LandscapeSchema),
647 | speed: v.optional(v.number()),
648 | squarish: v.optional(LandscapeSchema),
649 | squarishId: v.optional(v.string()),
650 | squarishURL: v.optional(v.string()),
651 | startImageURL: v.optional(v.string()),
652 | videoId: v.optional(v.string()),
653 | aspectRatio: v.optional(v.number()),
654 | manifestURL: v.optional(v.string()),
655 | providerId: v.optional(v.string()),
656 | assetId: v.optional(v.string()),
657 | startImage: v.optional(v.partial(CoverCardPropertiesSchema)),
658 | style: v.optional(StyleSchema),
659 | subtitle: v.string(),
660 | title: v.string(),
661 | })
662 |
663 | export const PurplePropertiesSchema = v.object({
664 | actions: v.array(v.unknown()),
665 | altText: v.string(),
666 | colorTheme: v.literal('dark'),
667 | copyId: v.string(),
668 | custom: v.optional(v.unknown()),
669 | fallbacks: v.optional(v.array(v.unknown())),
670 | imageCaption: v.optional(v.string()),
671 | internalName: v.optional(v.string()),
672 | landscape: LandscapeSchema,
673 | landscapeId: v.optional(v.string()),
674 | landscapeURL: v.string(),
675 | portrait: LandscapeSchema,
676 | portraitId: v.optional(v.string()),
677 | portraitURL: v.string(),
678 | product: v.optional(v.array(v.unknown())),
679 | richTextLinks: v.array(v.unknown()),
680 | secondaryPortrait: v.optional(SecondaryPortraitSchema),
681 | squarish: LandscapeSchema,
682 | squarishId: v.optional(v.string()),
683 | squarishURL: v.string(),
684 | style: v.optional(StyleSchema),
685 | subtitle: v.string(),
686 | title: v.string(),
687 | })
688 |
689 | export const NodeSchema = v.object({
690 | analytics: AnalyticsSchema,
691 | id: v.string(),
692 | properties: PurplePropertiesSchema,
693 | subType: ContainerTypeEnumSchema,
694 | type: v.literal('card'),
695 | version: v.string(),
696 | })
697 |
698 | export const PublishedContentNodeSchema = v.object({
699 | analytics: AnalyticsSchema,
700 | id: v.string(),
701 | nodes: v.optional(v.array(NodeSchema)),
702 | properties: PublishedContentNodePropertiesSchema,
703 | subType: ContainerTypeEnumSchema,
704 | type: v.literal('card'),
705 | version: v.string(),
706 | })
707 |
708 | export const PublishedContentSchema = v.object({
709 | analytics: AnalyticsSchema,
710 | collectionGroupId: v.string(),
711 | createdDateTime: v.string(),
712 | externalReferences: v.array(ExternalReferenceSchema),
713 | id: v.string(),
714 | language: v.string(),
715 | links: PublishedContentLinksSchema,
716 | marketplace: MarketplaceSchema,
717 | nodes: v.optional(v.array(PublishedContentNodeSchema)),
718 | payloadType: v.literal('thread'),
719 | preview: v.boolean(),
720 | properties: PublishedContentPropertiesSchema,
721 | publishEndDate: v.string(),
722 | publishStartDate: v.string(),
723 | resourceType: v.literal('publishedContent'),
724 | subType: v.union([v.literal('thread'), v.literal('story_format')]),
725 | supportedLanguages: v.array(v.unknown()),
726 | type: v.literal('thread'),
727 | version: v.string(),
728 | viewStartDate: v.string(),
729 | })
730 |
731 | export const ProductInfoSchema = v.object({
732 | availability: AvailabilitySchema,
733 | availableGtins: v.optional(v.array(AvailableGtinSchema)),
734 | fastAvailability: v.optional(FastAvailabilitySchema),
735 | launchView: v.optional(LaunchViewSchema),
736 | merchPrice: MerchPriceSchema,
737 | merchProduct: MerchProductSchema,
738 | productContent: ProductContentSchema,
739 | skus: v.array(SkusSchema),
740 | socialInterest: SocialInterestSchema,
741 | })
742 |
743 | export const SearchSchema = v.object({
744 | conceptIds: v.array(v.string()),
745 | })
746 |
747 | export const ProductFeedSchema = v.object({
748 | channelId: v.string(),
749 | channelName: v.literal('SNKRS Web'),
750 | collectionsv2: Collectionsv2Schema,
751 | collectionTermIds: v.array(v.unknown()),
752 | id: v.string(),
753 | language: v.string(),
754 | lastFetchTime: v.string(),
755 | links: ObjectLinksSchema,
756 | marketplace: MarketplaceSchema,
757 | productInfo: v.array(ProductInfoSchema),
758 | publishedContent: PublishedContentSchema,
759 | resourceType: v.literal('thread'),
760 | search: SearchSchema,
761 | })
762 |
763 | export const ProductFeedResponseSchema = createSnkrsRootResponseSchema(ProductFeedSchema)
764 | export type ProductFeedResponseOutput = v.InferOutput
765 |
766 | export type MarketplaceOutput = v.InferOutput
767 | export type Collectionsv2Output = v.InferOutput
768 | export type SelfOutput = v.InferOutput
769 | export type ObjectLinksOutput = v.InferOutput
770 | export type AvailabilityOutput = v.InferOutput
771 | export type MerchGroupOutput = v.InferOutput
772 | export type LevelOutput = v.InferOutput
773 | export type LocationIDOutput = v.InferOutput
774 | export type AvailableGtinOutput = v.InferOutput
775 | export type FastAvailabilityOutput = v.InferOutput
776 | export type LaunchViewMethodOutput = v.InferOutput
777 | export type LaunchViewOutput = v.InferOutput
778 | export type MerchPriceOutput = v.InferOutput
779 | export type PromoExclusionOutput = v.InferOutput
780 | export type BrandOutput = v.InferOutput
781 | export type ChannelOutput = v.InferOutput
782 | export type ConsumerChannelOutput = v.InferOutput
783 | export type CustomizationOutput = v.InferOutput
784 | export type GenderOutput = v.InferOutput
785 | export type LimitRetailExperienceValueOutput = v.InferOutput<
786 | typeof LimitRetailExperienceValueSchema
787 | >
788 | export type LimitRetailExperienceOutput = v.InferOutput
789 | export type ProductRollupOutput = v.InferOutput
790 | export type ProductTypeOutput = v.InferOutput
791 | export type PublishTypeOutput = v.InferOutput
792 | export type SportTagOutput = v.InferOutput
793 | export type StatusOutput = v.InferOutput
794 | export type TaxonomyAttributeOutput = v.InferOutput
795 | export type VasTypeOutput = v.InferOutput
796 | export type ValueAddedServiceOutput = v.InferOutput
797 | export type MerchProductOutput = v.InferOutput
798 | export type AthleteOutput = v.InferOutput
799 | export type ColorTypeOutput = v.InferOutput
800 | export type ColorOutput = v.InferOutput
801 | export type LangLocaleOutput = v.InferOutput
802 | export type WidthOutput = v.InferOutput
803 | export type ProductContentOutput = v.InferOutput
804 | export type CountrySpecificationOutput = v.InferOutput
805 | export type SkusOutput = v.InferOutput
806 | export type TaxInfoOutput = v.InferOutput
807 | export type SocialInterestOutput = v.InferOutput
808 | export type AnalyticsOutput = v.InferOutput
809 | export type ExternalReferenceOutput = v.InferOutput
810 | export type PublishedContentLinksOutput = v.InferOutput
811 | export type PortraitOutput = v.InferOutput
812 | export type LandscapeTypeOutput = v.InferOutput
813 | export type LandscapeOutput = v.InferOutput
814 | export type SecondaryPortraitOutput = v.InferOutput
815 | export type StylePropertiesOutput = v.InferOutput
816 | export type StyleOutput = v.InferOutput
817 | export type ContainerTypeEnumOutput = v.InferOutput
818 | export type CoverCardPropertiesOutput = v.InferOutput
819 | export type CoverCardOutput = v.InferOutput
820 | export type CustomOutput = v.InferOutput
821 | export type MetadataDecorationPayloadOutput = v.InferOutput
822 | export type MetadataDecorationOutput = v.InferOutput
823 | export type ProductOutput = v.InferOutput
824 | export type PublishOutput = v.InferOutput
825 | export type SEOOutput = v.InferOutput
826 | export type SocialOutput = v.InferOutput
827 | export type PublishedContentThreadTypeOutput = v.InferOutput<
828 | typeof PublishedContentThreadTypeSchema
829 | >
830 | export type PublishedContentPropertiesOutput = v.InferOutput<
831 | typeof PublishedContentPropertiesSchema
832 | >
833 | export type RichTextLinkOutput = v.InferOutput
834 | export type AttrsOutput = v.InferOutput
835 | export type MarkTypeOutput = v.InferOutput
836 | export type MarkOutput = v.InferOutput
837 | export type ContentInnerContentOutput = v.InferOutput
838 | export type JSONBodyContentOutput = v.InferOutput
839 | export type JSONBodyOutput = v.InferOutput
840 | export type DestinationOutput = v.InferOutput
841 | export type ActionOutput = v.InferOutput
842 | export type PublishedContentNodePropertiesOutput = v.InferOutput<
843 | typeof PublishedContentNodePropertiesSchema
844 | >
845 | export type PurplePropertiesOutput = v.InferOutput
846 | export type NodeOutput = v.InferOutput
847 | export type PublishedContentNodeOutput = v.InferOutput
848 | export type PublishedContentOutput = v.InferOutput
849 | export type ProductInfoOutput = v.InferOutput
850 | export type SearchOutput = v.InferOutput
851 | export type ProductFeedOutput = v.InferOutput
852 |
--------------------------------------------------------------------------------