317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
import { useMemo, useEffect, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import axios from 'axios'
|
|
import { trpc } from '../lib/trpc-client'
|
|
import type {
|
|
AllProductsApiType,
|
|
AvailabilityApiType,
|
|
StoresApiType,
|
|
SlotsApiType,
|
|
EssentialConstsApiType,
|
|
BannersApiType,
|
|
StoreWithProductsApiType,
|
|
} from '@backend/trpc/router'
|
|
import { CACHE_FILENAMES } from '@packages/shared'
|
|
|
|
export const useGetEssentialConsts = () => {
|
|
const query = trpc.common.essentialConsts.useQuery(undefined, {
|
|
refetchInterval: 60000,
|
|
})
|
|
return { ...query, refetch: query.refetch }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persisted cache helpers — store fetched JSON keyed by its versioned URL.
|
|
// Only refetch + repersist when the version (URL) changes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PersistedCache<T> {
|
|
version: string
|
|
data: T
|
|
}
|
|
|
|
const CACHE_STORAGE_KEYS = {
|
|
products: 'cache_products',
|
|
stores: 'cache_stores',
|
|
slots: 'cache_slots',
|
|
banners: 'cache_banners',
|
|
availability: 'cache_availability',
|
|
storeProducts: (storeId: number) => `cache_store_${storeId}`,
|
|
} as const
|
|
|
|
async function readPersistedCache<T>(key: string): Promise<PersistedCache<T> | null> {
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
if (!raw) return null
|
|
return JSON.parse(raw) as PersistedCache<T>
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function writePersistedCache<T>(key: string, version: string, data: T): Promise<void> {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify({ version, data }))
|
|
} catch (e) {
|
|
console.error('writePersistedCache error:', e)
|
|
}
|
|
}
|
|
|
|
function usePersistedCache<T>(storageKey: string, version: string | null) {
|
|
const [initialData, setInitialData] = useState<T | undefined>(undefined)
|
|
const [isReady, setIsReady] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setIsReady(false)
|
|
setInitialData(undefined)
|
|
if (!version) {
|
|
setIsReady(true)
|
|
return
|
|
}
|
|
readPersistedCache<T>(storageKey).then((persisted) => {
|
|
if (cancelled) return
|
|
if (persisted && persisted.version === version) {
|
|
setInitialData(persisted.data)
|
|
}
|
|
setIsReady(true)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [storageKey, version])
|
|
|
|
return { initialData, isReady }
|
|
}
|
|
|
|
type ProductsResponse = AllProductsApiType
|
|
type StoresResponse = StoresApiType
|
|
type SlotsResponse = SlotsApiType
|
|
type BannersResponse = BannersApiType
|
|
type StoreWithProductsResponse = StoreWithProductsApiType
|
|
type AvailabilityResponse = AvailabilityApiType
|
|
|
|
type BaseProduct = AllProductsApiType['products'][number]
|
|
type AvailabilityEntry = AvailabilityApiType['availability'][number]
|
|
|
|
export type MergedProduct = BaseProduct & {
|
|
price: number
|
|
marketPrice: number | null
|
|
flashPrice: string | null
|
|
isFlashAvailable: boolean
|
|
isOutOfStock: boolean
|
|
isSuspended: boolean
|
|
}
|
|
|
|
function useCacheUrl(filename: string): string | null {
|
|
const { data: essentialConsts } = useGetEssentialConsts()
|
|
|
|
const assetsDomain = essentialConsts?.assetsDomain
|
|
const apiCacheKey = essentialConsts?.apiCacheKey
|
|
const cacheVersion = essentialConsts?.cacheVersion
|
|
|
|
if (
|
|
!assetsDomain ||
|
|
!apiCacheKey ||
|
|
cacheVersion === undefined ||
|
|
cacheVersion === null
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}`
|
|
}
|
|
|
|
function useAvailabilityCacheUrl(): string | null {
|
|
const { data: essentialConsts } = useGetEssentialConsts()
|
|
|
|
const assetsDomain = essentialConsts?.assetsDomain
|
|
const availabilityVersionNum = essentialConsts?.availabilityVersionNum
|
|
|
|
if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) {
|
|
return null
|
|
}
|
|
|
|
return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}`
|
|
}
|
|
|
|
function useSlotsCacheUrl(): string | null {
|
|
const { data: essentialConsts } = useGetEssentialConsts()
|
|
|
|
const assetsDomain = essentialConsts?.assetsDomain
|
|
const slotsVersionNum = essentialConsts?.slotsVersionNum
|
|
|
|
if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) {
|
|
return null
|
|
}
|
|
|
|
return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}`
|
|
}
|
|
|
|
export function useAllProducts() {
|
|
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
|
|
const { data: availabilityData } = useAvailability()
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<ProductsResponse>(CACHE_STORAGE_KEYS.products, version)
|
|
|
|
const productsQuery = useQuery<ProductsResponse>({
|
|
queryKey: ['all-products', version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<ProductsResponse>(cacheUrl)
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.products, version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
|
|
const mergedProducts = useMemo(() => {
|
|
const rawProducts = productsQuery.data?.products || []
|
|
const availabilityById: Record<number, AvailabilityEntry> = {}
|
|
availabilityData?.availability?.forEach((entry: AvailabilityEntry) => {
|
|
availabilityById[entry.id] = entry
|
|
})
|
|
|
|
return rawProducts.map((product) => {
|
|
const availability = availabilityById[product.id]
|
|
return {
|
|
...product,
|
|
price: availability ? Number(availability.price) : 0,
|
|
marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null,
|
|
flashPrice: availability?.flashPrice ?? null,
|
|
isFlashAvailable: availability?.isFlashAvailable ?? false,
|
|
isOutOfStock: availability?.isOutOfStock ?? false,
|
|
isSuspended: availability?.isSuspended ?? false,
|
|
}
|
|
})
|
|
}, [productsQuery.data, availabilityData])
|
|
|
|
const mergedData = useMemo(() => {
|
|
if (!productsQuery.data) return undefined
|
|
return {
|
|
...productsQuery.data,
|
|
products: mergedProducts,
|
|
} as ProductsResponse & { products: MergedProduct[] }
|
|
}, [productsQuery.data, mergedProducts])
|
|
|
|
return {
|
|
...productsQuery,
|
|
data: mergedData,
|
|
}
|
|
}
|
|
|
|
export function useAvailability() {
|
|
const cacheUrl = useAvailabilityCacheUrl()
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<AvailabilityResponse>(CACHE_STORAGE_KEYS.availability, version)
|
|
|
|
return useQuery<AvailabilityResponse>({
|
|
queryKey: ['availability', version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<AvailabilityResponse>(cacheUrl)
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.availability, version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
}
|
|
|
|
export function useStores() {
|
|
const cacheUrl = useCacheUrl(CACHE_FILENAMES.stores)
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<StoresResponse>(CACHE_STORAGE_KEYS.stores, version)
|
|
|
|
return useQuery<StoresResponse>({
|
|
queryKey: ['stores', version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<StoresResponse>(cacheUrl)
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.stores, version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
}
|
|
|
|
export function useSlots() {
|
|
const cacheUrl = useSlotsCacheUrl()
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<SlotsResponse>(CACHE_STORAGE_KEYS.slots, version)
|
|
|
|
return useQuery<SlotsResponse>({
|
|
queryKey: ['slots', version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<SlotsResponse>(cacheUrl + '?v=123')
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.slots, version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
}
|
|
|
|
export function useBanners() {
|
|
const cacheUrl = useCacheUrl(CACHE_FILENAMES.banners)
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<BannersResponse>(CACHE_STORAGE_KEYS.banners, version)
|
|
|
|
return useQuery<BannersResponse>({
|
|
queryKey: ['banners', version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<BannersResponse>(cacheUrl)
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.banners, version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
}
|
|
|
|
export function useStoreWithProducts(storeId: number) {
|
|
const { data: essentialConsts } = useGetEssentialConsts()
|
|
|
|
const assetsDomain = essentialConsts?.assetsDomain
|
|
const apiCacheKey = essentialConsts?.apiCacheKey
|
|
const cacheVersion = essentialConsts?.cacheVersion
|
|
|
|
const cacheUrl =
|
|
assetsDomain && apiCacheKey
|
|
? `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/stores/${storeId}.json`
|
|
: null
|
|
const version = cacheUrl ?? ''
|
|
const { initialData, isReady } = usePersistedCache<StoreWithProductsResponse>(CACHE_STORAGE_KEYS.storeProducts(storeId), version)
|
|
|
|
return useQuery<StoreWithProductsResponse>({
|
|
queryKey: ['store-with-products', storeId, version],
|
|
queryFn: async () => {
|
|
if (!cacheUrl) {
|
|
throw new Error('Cache URL not available')
|
|
}
|
|
const response = await axios.get<StoreWithProductsResponse>(cacheUrl)
|
|
await writePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version, response.data)
|
|
return response.data
|
|
},
|
|
staleTime: 60000,
|
|
enabled: !!cacheUrl && isReady,
|
|
placeholderData: initialData,
|
|
})
|
|
}
|