Initial Commit, final submission

This commit is contained in:
Eclypsed
2024-12-19 18:21:27 -05:00
commit 87f1c79481
29 changed files with 4126 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
.env
.env.*
!.env.example
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"tabWidth": 4,
"singleQuote": true,
"semi": false,
"printWidth": 160,
"bracketSpacing": true,
"plugins": ["prettier-plugin-tailwindcss"]
}

2665
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "cs343-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/node": "^22.9.3",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.14",
"postcss": "^8.4.47",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.2",
"vite": "^5.4.9"
},
"dependencies": {
"idb": "^8.0.0",
"ky": "^1.7.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

19
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare global {
type Product = {
id: string
name: string
seller: string
modelNumber: string
price: {
amount: number
currency: string
}
inStock: boolean
stockAmount: number
imageUrl: URL
releaseDate: Date
distributer: string
}
}
export {}

BIN
src/images/albumcollage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,59 @@
interface AlertDetail {
type: 'alert-info' | 'alert-warning' | 'alert-success' | 'alert-error'
message: string
}
class AlertEvent extends CustomEvent<AlertDetail> {
constructor(detail: AlertDetail) {
super('alert', { detail })
}
}
const alertTemplate = document.createElement('template')
alertTemplate.innerHTML = `
<div class="alert">
<span></span>
</div>
`
class Alert extends HTMLElement {
constructor(alertDetail: AlertDetail) {
super()
this.appendChild(alertTemplate.content.cloneNode(true))
const outer = this.querySelector<HTMLDivElement>('div')!
outer.classList.add(alertDetail.type)
const message = this.querySelector<HTMLSpanElement>('span')!
message.textContent = alertDetail.message
}
}
const alertBoxTemplate = document.createElement('template')
alertBoxTemplate.innerHTML = `
<div id="alert-box" class="toast toast-top toast-end"></div>
`
class AlertBox extends HTMLElement {
private readonly alertBox: HTMLDivElement
constructor() {
super()
this.appendChild(alertBoxTemplate.content.cloneNode(true))
this.alertBox = this.querySelector<HTMLDivElement>('#alert-box')!
}
public add = (alertDetail: AlertDetail) => {
const alert = new Alert(alertDetail)
this.alertBox.appendChild(alert)
setTimeout(() => alert.remove(), 7000)
}
public static define() {
customElements.define('alert-element', Alert)
customElements.define('alert-box', AlertBox)
}
}
export default AlertBox
export { AlertEvent, type AlertDetail }

View File

@@ -0,0 +1,119 @@
import CurrencyManager, { type Currency } from '@lib/currency'
const currencyOptionTemplate = document.createElement('template')
currencyOptionTemplate.innerHTML = `
<li>
<button>
<span id="badge" class="badge badge-sm badge-outline font-mono !text-[.6rem] font-bold tracking-widest opacity-50"></span>
<span id="currency-name" class="text-nowrap line-clamp-1 overflow-ellipsis text-sm font-sans text-neutral-content"></span>
</button>
</li>
`
class CurrencyOption extends HTMLElement {
public readonly currency: Currency
private readonly optionButton: HTMLButtonElement
constructor(currency: Currency) {
super()
this.appendChild(currencyOptionTemplate.content.cloneNode(true))
this.currency = currency
this.optionButton = this.querySelector<HTMLButtonElement>('button')!
this.querySelector<HTMLSpanElement>('#badge')!.innerText = this.currency.code
this.querySelector<HTMLSpanElement>('#currency-name')!.innerText = this.currency.name
}
public set active(active: boolean) {
active ? this.optionButton.classList.add('active') : this.optionButton.classList.remove('active')
}
public set onclick(handler: GlobalEventHandlers['onclick']) {
this.optionButton.onclick = handler
}
}
interface CurrencyChangeEventDetail {
newCurrencyCode: string
}
class CurrencyChangeEvent extends CustomEvent<CurrencyChangeEventDetail> {
constructor(detail: CurrencyChangeEventDetail) {
super('currencyChanged', { detail })
}
}
const currencySelectorTemplate = document.createElement('template')
currencySelectorTemplate.innerHTML = `
<div class="dropdown dropdown-bottom dropdown-end w-full">
<button class="btn btn-ghost flex-nowrap w-full">
<span id="active-currency" class="opacity-60"></span>
<svg
width="12px"
height="12px"
class="hidden h-2 w-2 fill-current opacity-60 sm:inline-block"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2048 2048"
>
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path>
</svg>
</button>
<div class="dropdown-content menu menu-sm bg-base-200 rounded-box border-neutral my-2 w-64 border p-2 shadow">
<ul id="options" class="max-h-96 overflow-y-scroll flex flex-col gap-1"></ul>
</div>
</div>`
class CurrencySelector extends HTMLElement {
private readonly currencyOptions: Map<string, CurrencyOption>
public oncurrencychange: ((event: CurrencyChangeEvent) => any) | null = null
constructor() {
super()
this.currencyOptions = new Map()
this.appendChild(currencySelectorTemplate.content.cloneNode(true))
const currencyOptionList = this.querySelector<HTMLUListElement>('#options')!
CurrencyManager.getEnabledCurrencies().then((currencies) => {
currencies.forEach((currency) => {
const option = this.addCurrencyOption(currency)
currencyOptionList.appendChild(option)
})
this.updateActiveCurrency(CurrencyManager.activeCurrency)
})
}
private addCurrencyOption(currency: Currency): CurrencyOption {
const option = new CurrencyOption(currency)
option.onclick = () => this.updateActiveCurrency(currency.code)
option.active = currency.code === CurrencyManager.activeCurrency
this.currencyOptions.set(currency.code, option)
return option
}
private updateActiveCurrency(currencyCode: string) {
const newOption = this.currencyOptions.get(currencyCode)
if (!newOption) return
this.currencyOptions.forEach((option) => (option.active = false))
CurrencyManager.activeCurrency = currencyCode
newOption.active = true
const activeCurrency = this.querySelector<HTMLSpanElement>('#active-currency')!
activeCurrency.innerText = currencyCode
if (this.oncurrencychange) {
this.oncurrencychange(new CurrencyChangeEvent({ newCurrencyCode: currencyCode }))
}
}
public static define() {
customElements.define('currency-option', CurrencyOption)
customElements.define('currency-selector', CurrencySelector)
}
}
export default CurrencySelector

View File

@@ -0,0 +1,96 @@
import CurrencyManager, { type CurrencyDBEntry } from '@lib/currency'
const tableRowTemplate = document.createElement('template')
tableRowTemplate.innerHTML = `
<tr>
<td>
<span id="code" class="badge badge-sm badge-outline font-mono !text-[.6rem] font-bold tracking-widest opacity-50"></span>
</td>
<td id="name"></td>
<td>
<input type="checkbox" class="toggle toggle-sm toggle-accent" />
</td>
</tr>
`
const tableSectionTemplate = document.createElement('template')
tableSectionTemplate.innerHTML = `
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
`
const template = document.createElement('template')
template.innerHTML = `
<table class="table table-sm table-zebra table-pin-rows"></table>
`
class CurrencyTable extends HTMLElement {
constructor() {
super()
this.appendChild(template.content.cloneNode(true))
const table = this.querySelector<HTMLTableElement>('table')!
CurrencyManager.getAllCurrencies().then((currencies) => {
const groups = this.groupAlphabetically(currencies)
Object.entries(groups).forEach(([letter, currencies]) => table.appendChild(this.buildSection(letter, currencies)))
})
}
private groupAlphabetically = (currencies: CurrencyDBEntry[]): Record<string, CurrencyDBEntry[]> => {
const letterGroups: Record<string, CurrencyDBEntry[]> = {}
currencies.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
currencies.forEach((currency) => {
const firstLetter = currency.name[0].toUpperCase()
if (!letterGroups[firstLetter]) letterGroups[firstLetter] = []
letterGroups[firstLetter].push(currency)
})
return letterGroups
}
private buildSection = (header: string, currencies: CurrencyDBEntry[]): Node => {
const section = tableSectionTemplate.content.cloneNode(true) as DocumentFragment
const th = section.querySelector<HTMLTableCellElement>('th')!
th.textContent = header
const body = section.querySelector<HTMLTableSectionElement>('tbody')!
currencies.forEach((currency) => body.appendChild(this.buildRow(currency)))
return section
}
private buildRow = (currency: CurrencyDBEntry): Node => {
const row = tableRowTemplate.content.cloneNode(true) as DocumentFragment
const codeCell = row.querySelector<HTMLSpanElement>('#code')!
codeCell.textContent = currency.code
const nameCell = row.querySelector<HTMLTableCellElement>('#name')!
nameCell.textContent = currency.name
const toggle = row.querySelector<HTMLInputElement>('input[type="checkbox"]')!
toggle.name = currency.name
toggle.checked = currency.enabled
toggle.onchange = () => CurrencyManager.setCurrencyEnabled(currency.code, toggle.checked)
return row
}
public static define() {
customElements.define('currency-table', CurrencyTable)
}
}
export default CurrencyTable

View File

@@ -0,0 +1,104 @@
import type { Currency } from '@lib/currency'
import ProductManager from '@lib/products'
const template = document.createElement('template')
template.innerHTML = `
<div class="card card-compact bg-base-100 relative">
<figure class="px-5 pt-5">
<a id="page-link">
<img id="image" class="h-full w-full rounded-lg object-cover" />
</a>
</figure>
<div class="card-body items-center text-center !gap-3">
<div>
<h2 id="title" class="line-clamp-1 card-title !text-lg"></h2>
<h3 id="subtitle" class="line-clamp-1 text-sm"></h3>
</div>
<div class="card-actions justify-center">
<div id="stock-amount" class="tooltip tooltip-success">
<div id="stock-status" class="badge badge-outline"></div>
</div>
<div id="price" class="badge badge-neutral"></div>
</div>
</div>
<div class="tooltip absolute -top-2.5 -right-2.5" data-tip="Delete">
<button id="delete" class="btn btn-circle btn-sm bg-base-100">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
`
class ProductCard extends HTMLElement {
public readonly product: Product
private readonly priceElement: HTMLDivElement
constructor(product: Product) {
super()
this.product = product
this.appendChild(template.content.cloneNode(true))
const pageLink = this.querySelector<HTMLAnchorElement>('#page-link')!
pageLink.href = `https://www.tanocstore.net/shopdetail/${product.id.padStart(12, '0')}`
pageLink.target = '_blank'
const image = this.querySelector<HTMLImageElement>('#image')!
image.src = this.product.imageUrl.toString()
image.alt = `${this.product.name} Jacket`
const title = this.querySelector<HTMLHeadingElement>('#title')!
title.textContent = this.product.name
const subtitle = this.querySelector<HTMLHeadingElement>('#subtitle')!
subtitle.textContent = `${this.product.seller} / ${this.product.modelNumber}`
if (product.inStock) {
const stockAmount = this.querySelector<HTMLDivElement>('#stock-amount')!
stockAmount.dataset.tip = `Stock: ${product.stockAmount}`
}
const stockStatus = this.querySelector<HTMLDivElement>('#stock-status')!
if (this.product.inStock) {
stockStatus.classList.remove('badge-error')
stockStatus.classList.add('badge-success')
stockStatus.textContent = 'In Stock'
} else {
stockStatus.classList.remove('badge-success')
stockStatus.classList.add('badge-error')
stockStatus.textContent = 'Out of Stock'
}
this.priceElement = this.querySelector<HTMLDivElement>('#price')!
this.priceElement.textContent = `${this.product.price.amount} ${this.product.price.currency}`
const deleteButton = this.querySelector<HTMLButtonElement>('#delete')!
deleteButton.onclick = this.delete
}
public convertCurrency = (newCurrency: Currency, exchangeRate: number) => {
const newAmount = this.product.price.amount / exchangeRate
this.priceElement.textContent = `${newAmount.toFixed(newCurrency.decimal_digits)} ${newCurrency.code}`
}
private delete = () => {
ProductManager.delete(this.product.id)
this.remove()
}
public static define() {
customElements.define('product-card', ProductCard)
}
}
export default ProductCard

View File

@@ -0,0 +1,218 @@
import TanocStore from '@lib/tanocStore'
import ProductManager from '@lib/products'
import { AlertEvent } from './alertBox'
const spinner = `<span class="loading loading-spinner" />`
const searchSVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-4 w-4" fill="currentColor">
<!--!Font Awesome Free 6.7.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<path
d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"
/>
</svg>
`
const productPreviewTemplate = document.createElement('template')
productPreviewTemplate.innerHTML = `
<div class="card card-side card-compact bg-neutral shadow-xl h-24">
<figure class="flex-shrink-0">
<img id="preview-image" class="h-full aspect-square object-cover" />
</figure>
<div class="flex flex-col flex-auto p-4 justify-center overflow-hidden w-full">
<h2 id="product-name" class="card-title line-clamp-1"></h2>
<div class="text-neutral-content flex items-center gap-2">
<span id="seller" class="line-clamp-1"></span>
<div id="stock-indicator" class="flex-shrink-0 badge badge-outline"></div>
</div>
</div>
</div>
`
class ProductPreview extends HTMLElement {
public readonly product: Product
constructor(product: Product) {
super()
this.product = product
this.appendChild(productPreviewTemplate.content.cloneNode(true))
const previewImage = this.querySelector<HTMLImageElement>('#preview-image')!
previewImage.src = product.imageUrl.toString()
previewImage.alt = `${product.name} display image`
const productName = this.querySelector<HTMLHeadingElement>('#product-name')!
productName.textContent = product.name
const seller = this.querySelector<HTMLSpanElement>('#seller')!
seller.textContent = product.seller
const stockIndicator = this.querySelector<HTMLDivElement>('#stock-indicator')!
stockIndicator.textContent = product.inStock ? 'In Stock' : 'Out of Stock'
stockIndicator.classList.add(product.inStock ? 'badge-success' : 'badge-error')
}
}
const template = document.createElement('template')
template.innerHTML = `
<dialog id="modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Add Item</h3>
<form method="dialog" class="flex gap-4 my-4">
<input type="text" name="item-input" class="input input-bordered w-full" placeholder="Enter URL or product ID" />
<button id="search-button" type="button" disabled class="btn btn-square">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-4 w-4" fill="currentColor">
<!--!Font Awesome Free 6.7.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<path
d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"
/>
</svg>
</button>
</form>
<div class="modal-action">
<button id="add-item-button" disabled class="btn">Add Item</button>
<button id="close-button" class="btn">Close</button>
</div>
</div>
</dialog>
`
class ProductSearchModal extends HTMLElement {
private readonly modal: HTMLDialogElement
private readonly input: HTMLInputElement
private readonly form: HTMLFormElement
private readonly searchButton: HTMLButtonElement
private readonly addItemButton: HTMLButtonElement
private readonly closeButton: HTMLButtonElement
public onadd: ((addedProduct: Product) => any) | null = null
constructor() {
super()
this.appendChild(template.content.cloneNode(true))
this.modal = this.querySelector<HTMLDialogElement>('dialog')!
this.form = this.querySelector<HTMLFormElement>('form')!
this.input = this.querySelector<HTMLInputElement>('input[type="text"]')!
this.input.oninput = this.inputHandle
this.input.onchange = this.changeHandle
this.searchButton = this.querySelector<HTMLButtonElement>('#search-button')!
this.searchButton.onclick = this.searchHandle
this.addItemButton = this.querySelector<HTMLButtonElement>('#add-item-button')!
this.addItemButton.onclick = this.addItem
this.closeButton = this.querySelector<HTMLButtonElement>('#close-button')!
this.closeButton.onclick = this.close
}
public open = () => {
this.modal.showModal()
}
public close = () => {
this.modal.close()
this.input.value = ''
this.inputState = 'empty'
setTimeout(() => (this.searchResult = null), 100)
}
private set inputState(state: 'valid' | 'invalid' | 'empty') {
switch (state) {
case 'valid':
this.input.classList.remove('input-error')
this.input.classList.add('input-success')
this.searchButton.disabled = false
break
case 'invalid':
this.input.classList.remove('input-success')
this.input.classList.add('input-error')
this.searchButton.disabled = true
break
case 'empty':
this.input.classList.remove('input-success', 'input-error')
this.searchButton.disabled = true
break
}
}
private get searchResult(): Product | null {
return this.querySelector<ProductPreview>('product-preview')?.product ?? null
}
private set searchResult(product: Product | null) {
const currentResult = this.querySelector<ProductPreview>('product-preview')
if (product === null) {
currentResult?.remove()
return
}
const newPreview = new ProductPreview(product)
if (currentResult === null) {
this.form.insertAdjacentElement('afterend', newPreview)
} else {
currentResult.replaceWith(newPreview)
}
}
private inputHandle: GlobalEventHandlers['oninput'] = () => {
const value = this.input.value
if (value.length === 0) {
this.inputState = 'empty'
return
}
const validURL = URL.canParse(value) && /^https?:\/\/(?:www\.)?tanocstore\.net\/shopdetail\/\d{12}/.test(value)
const validID = /^\d{1,12}$/.test(value) && Number.parseInt(value) > 0
this.inputState = validURL || validID ? 'valid' : 'invalid'
}
private changeHandle: GlobalEventHandlers['onchange'] = () => {
const value = this.input.value
if (/^\d{1,11}$/.test(value)) this.input.value = value.padStart(12, '0')
}
private searchHandle: GlobalEventHandlers['onclick'] = async () => {
const productId = URL.canParse(this.input.value) ? /\d{12}/.exec(this.input.value)![0] : this.input.value
this.searchButton.disabled = true
this.searchButton.innerHTML = spinner
const product = await TanocStore.product(productId).catch(() => null)
this.searchButton.innerHTML = searchSVG
this.searchButton.disabled = false
if (!product) {
this.dispatchEvent(new AlertEvent({ message: 'Invalid URL/ID', type: 'alert-error' }))
this.inputState = 'invalid'
return
}
this.searchResult = product
this.addItemButton.disabled = false
}
private addItem = () => {
if (this.searchResult === null) return
if (ProductManager.products.has(this.searchResult.id)) {
this.dispatchEvent(new AlertEvent({ message: 'Product already being tracked', type: 'alert-warning' }))
return
}
ProductManager.add(this.searchResult.id)
if (this.onadd) this.onadd(this.searchResult)
}
public static define() {
customElements.define('product-preview', ProductPreview)
customElements.define('product-search-modal', ProductSearchModal)
}
}
export default ProductSearchModal

View File

@@ -0,0 +1,42 @@
import CurrencyManager from '@lib/currency'
const template = document.createElement('template')
template.innerHTML = `
<button class="btn btn-primary">Refresh Exchange Rates</button>
`
class RefreshExchangeRatesButton extends HTMLElement {
private readonly button: HTMLButtonElement
public onrefresh: (() => any) | null = null
public onfail: (() => any) | null = null
constructor() {
super()
this.appendChild(template.content.cloneNode(true))
this.button = this.querySelector('button')!
this.button.onclick = this.handleClick
}
private handleClick: GlobalEventHandlers['onclick'] = async () => {
this.button.disabled = true
this.button.innerHTML = `
<span class="loading loading-spinner"></span>
Refreshing
`
try {
await CurrencyManager.refreshExchangeRates()
this.onrefresh?.call(null)
} catch {
this.onfail?.call(null)
}
this.button.innerText = `Refresh Exchange Rates`
this.button.disabled = false
}
public static define(): void {
customElements.define('refresh-exchange-rates-button', RefreshExchangeRatesButton)
}
}
export default RefreshExchangeRatesButton

View File

@@ -0,0 +1,27 @@
import TanocStoreLogoImage from '@images/tanocStoreLogo.png'
const template = document.createElement('template')
template.innerHTML = `
<a class="bg-primary inline-block" href="https://www.tanocstore.net/" target="_blank">
<img alt="TANO*C STORE Logo" class="opacity-0" aria-hidden="true" />
</a>
`
class TanocStoreLogo extends HTMLElement {
constructor() {
super()
this.appendChild(template.content.cloneNode(true))
const image = this.querySelector('img')!
image.src = TanocStoreLogoImage
const link = this.querySelector('a')!
link.style.maskImage = `url("${TanocStoreLogoImage}")`
}
public static define() {
customElements.define('tanoc-store-logo', TanocStoreLogo)
}
}
export default TanocStoreLogo

179
src/lib/currency.ts Normal file
View File

@@ -0,0 +1,179 @@
import ky from 'ky'
import { IDBPDatabase, openDB, StoreNames, type DBSchema } from 'idb'
export type Currency = {
symbol: string
name: string
code: string
decimal_digits: number
}
class CurrencyAPI {
private static readonly api = ky.create({
prefixUrl: 'https://api.currencyapi.com/v3/',
method: 'get',
headers: { apikey: import.meta.env.VITE_CURRENCY_API_KEY },
})
public static async getCurrencies(): Promise<Record<string, Currency>> {
console.log('Fetched new currencies')
const response = await this.api('currencies').json<{ data: Record<string, Currency> }>()
return response.data
}
public static async getExchangeRates(currencies?: Iterable<string>, baseCurrency: string = 'USD'): Promise<Record<string, number>> {
const searchParams = new URLSearchParams()
searchParams.set('base_currency', baseCurrency)
if (currencies) searchParams.set('currencies', Array.from(currencies).join(','))
console.log('Fetched new exchange rates')
const response = await this.api('latest', { searchParams }).json<{ data: Record<string, { code: string; value: number }> }>()
const exchangeRates: Record<string, number> = {}
Object.values(response.data).forEach(({ code, value }) => (exchangeRates[code] = value))
return exchangeRates
}
}
export type CurrencyDBEntry = Currency & { enabled: boolean }
interface CurrencyDBSchema extends DBSchema {
currencies: {
key: string // Currency Code
value: CurrencyDBEntry
}
// All exchange rates are in relation to USD (base_currency in CurrencyAPI)
exchangeRates: {
key: string // Currency Code
value: number
}
}
const DEFAULT_CURRENCY = 'USD'
const DEFAULT_ENABLED_CURRENCIES = new Set<string>([DEFAULT_CURRENCY, 'EUR', 'GBP', 'JPY', 'CAD', 'AUD'])
class CurrencyDB {
private static dbInstance: IDBPDatabase<CurrencyDBSchema> | null = null
private static readonly storeConfig = new Map<StoreNames<CurrencyDBSchema>, IDBObjectStoreParameters | undefined>([
['currencies', { keyPath: 'code' }],
['exchangeRates', undefined],
])
private static async initDB(): Promise<IDBPDatabase<CurrencyDBSchema>> {
const db = await openDB<CurrencyDBSchema>('Currency', 1, {
upgrade: (db) =>
this.storeConfig.forEach((config, storeName) => {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, config)
}
}),
})
const countTx = db.transaction(['currencies', 'exchangeRates'], 'readonly')
const currencyEntryCount = await countTx.objectStore('currencies').count()
const exchangeRatesEntryCount = await countTx.objectStore('exchangeRates').count()
await countTx.done
if (currencyEntryCount === 0) {
const currencies = await CurrencyAPI.getCurrencies()
const currecyDBEntries = Object.values(currencies).map(
(currency): CurrencyDBEntry => Object.assign(currency, { enabled: DEFAULT_ENABLED_CURRENCIES.has(currency.code) }),
)
const currencyTx = db.transaction('currencies', 'readwrite')
await Promise.all(currecyDBEntries.map((currency) => currencyTx.store.put(currency)))
await currencyTx.done
}
if (exchangeRatesEntryCount === 0) {
await this.initExchangeRates(db)
}
return db
}
public static async initExchangeRates(db: IDBPDatabase<CurrencyDBSchema>) {
const newExchangeRates = await CurrencyAPI.getExchangeRates()
const tx = db.transaction('exchangeRates', 'readwrite')
await tx.store.clear()
await Promise.all(Object.entries(newExchangeRates).map(([key, val]) => tx.store.add(val, key)))
await tx.done
}
public static async open(): Promise<IDBPDatabase<CurrencyDBSchema>> {
if (this.dbInstance === null) {
this.dbInstance = await this.initDB()
}
return this.dbInstance
}
}
const db = await CurrencyDB.open()
class CurrencyManager {
public static get activeCurrency(): string {
const storedCurrency = localStorage.getItem('currency')
if (storedCurrency === null) {
this.activeCurrency = DEFAULT_CURRENCY
return DEFAULT_CURRENCY
} else {
return storedCurrency
}
}
public static set activeCurrency(currencyCode: string) {
localStorage.setItem('currency', currencyCode)
}
public static async getCurrency(currencyCode: string): Promise<CurrencyDBEntry | undefined> {
return await db.get('currencies', currencyCode)
}
public static async getAllCurrencies(): Promise<CurrencyDBEntry[]> {
return await db.getAll('currencies')
}
public static async getEnabledCurrencies(): Promise<CurrencyDBEntry[]> {
const enabledCurrencies: Array<Currency & { enabled: boolean }> = []
const tx = db.transaction('currencies')
for await (const cursor of tx.store) {
if (cursor.value.enabled) enabledCurrencies.push(cursor.value)
}
return enabledCurrencies
}
public static async setCurrencyEnabled(currencyCode: string, enabled: boolean) {
const tx = db.transaction('currencies', 'readwrite')
const store = tx.store
const currency = await store.get(currencyCode)
if (!currency) throw Error(`Invalid currency code: ${currencyCode}`)
currency.enabled = enabled
await store.put(currency)
await tx.done
if (!enabled && this.activeCurrency === currency.code) {
this.activeCurrency = DEFAULT_CURRENCY
}
}
public static async getExchangeRate(fromCurrencyCode: string, toCurrencyCode: string): Promise<number | undefined> {
const toUSD = await db.get('exchangeRates', fromCurrencyCode)
const toNewCurrency = await db.get('exchangeRates', toCurrencyCode)
if (!toUSD || !toNewCurrency) return undefined
return toUSD / toNewCurrency
}
public static async setExchangeRate(currencyCode: string, exchangeRate: number): Promise<string> {
return await db.put('exchangeRates', exchangeRate, currencyCode)
}
public static refreshExchangeRates = async () => {
await CurrencyDB.initExchangeRates(db)
}
}
export default CurrencyManager

41
src/lib/products.ts Normal file
View File

@@ -0,0 +1,41 @@
function isStringArray(json: any): json is string[] {
return Array.isArray(json) && json.every((item) => typeof item === 'string')
}
function canParseJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString)
return true
} catch {
return false
}
}
class ProductManager {
public static get products(): Set<string> {
const existingIdsString = localStorage.getItem('products')
if (existingIdsString && canParseJSON(existingIdsString)) {
const existingIds = JSON.parse(existingIdsString)
if (isStringArray(existingIds)) {
return new Set(existingIds)
}
}
return new Set()
}
public static add(productId: string) {
const ids = this.products
ids.add(productId)
localStorage.setItem('products', JSON.stringify(Array.from(ids)))
}
public static delete(productId: string) {
const ids = this.products
ids.delete(productId)
localStorage.setItem('products', JSON.stringify(Array.from(ids)))
}
}
export default ProductManager

72
src/lib/tanocStore.ts Normal file
View File

@@ -0,0 +1,72 @@
import ky from 'ky'
const JST_OFFSET = 9
const api = ky.create({
prefixUrl: 'https://makeshop.worldshopping.jp/prod/',
method: 'get',
searchParams: { shopKey: 'tanocstore_net' },
})
class TanocStore {
public static async product(productId: string): Promise<Product> {
productId = productId.padStart(12, '0')
const productData = await api('product', { searchParams: { productId } }).json<WSAPI.Product>()
const { product_name, price, vendor, ubrand_code, stock, image_url, created_date } = productData
const stockAmount = Number.parseInt(stock)
return {
id: productId,
name: product_name,
seller: vendor,
modelNumber: ubrand_code,
price: {
amount: Number(price),
currency: 'JPY',
},
inStock: stockAmount > 0,
stockAmount,
imageUrl: new URL(image_url),
releaseDate: parseDateString(created_date, JST_OFFSET),
distributer: 'TANO*C Store',
}
}
}
function parseDateString(dateString: string, timezoneOffset: number = 0): Date {
const year = Number.parseInt(dateString.substring(0, 4), 10)
const month = Number.parseInt(dateString.substring(4, 6), 10) - 1
const day = Number.parseInt(dateString.substring(6, 8), 10)
const hour = Number.parseInt(dateString.substring(8, 10), 10)
const minute = Number.parseInt(dateString.substring(10, 12), 10)
const second = Number.parseInt(dateString.substring(12, 14), 10)
return new Date(Date.UTC(year, month, day, hour - timezoneOffset, minute, second))
}
export default TanocStore
declare namespace WSAPI {
type Product = {
created_date: string //yyyymmddhhmmss
ubrand_code: string // Product code
categories: Array<{
category_code: string
category_path: string
}>
product_name: string
price: string
vendor: string // Label
stock: string
image_url: string
main_content: string
}
type ExtraProductDetails = {
stockAmount: number
categories: string[]
videoUrl?: string
}
}

36
src/lib/themes.ts Normal file
View File

@@ -0,0 +1,36 @@
type Theme = 'dark' | 'light' | 'synthwave' | 'valentine' | 'aqua' | 'dracula'
class ThemeManager {
public static readonly DEFAULT_THEME: Theme = 'dark'
public static readonly VALID_THEMES = new Set<Theme>(['dark', 'light', 'synthwave', 'valentine', 'aqua', 'dracula'])
public static get activeTheme(): Theme {
const storedTheme = localStorage.getItem('theme')
if (!storedTheme || !this.validTheme(storedTheme)) {
this.activeTheme = this.DEFAULT_THEME
return this.DEFAULT_THEME
}
return storedTheme
}
public static set activeTheme(theme: Theme) {
localStorage.setItem('theme', theme)
}
public static validTheme(theme: string): theme is Theme {
return this.VALID_THEMES.has(theme as Theme)
}
}
function loadTheme() {
const html = document.querySelector('html')
if (!html) {
console.error('Failed to load theme')
return
}
html.dataset.theme = ThemeManager.activeTheme
}
export default ThemeManager
export { loadTheme, type Theme }

54
src/routes/app.css Normal file
View File

@@ -0,0 +1,54 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@100..900&family=Noto+Sans+JP:wght@100..900&family=Noto+Sans+KR:wght@100..900&family=Noto+Sans+SC:wght@100..900&family=Noto+Sans+TC:wght@100..900&family=Noto+Sans:wght@100..900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Yes, there is a reason I am not using repeat(auto-fill) here */
#card-wrapper {
grid-template-columns: 1fr;
}
@media (min-width: 400px) {
#card-wrapper {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 700px) {
#card-wrapper {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1050px) {
#card-wrapper {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1350px) {
#card-wrapper {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1700px) {
#card-wrapper {
grid-template-columns: repeat(6, 1fr);
}
}
@media (min-width: 2300px) {
#card-wrapper {
grid-template-columns: repeat(7, 1fr);
}
}
@media (min-width: 3000px) {
#card-wrapper {
grid-template-columns: repeat(8, 1fr);
}
}

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nicholas Tamassia - CS343 Project - Dashboard</title>
<link rel="stylesheet" href="../app.css" />
<script type="module">
import { loadTheme } from '@lib/themes'
loadTheme()
</script>
</head>
<body>
<div class="no-scrollbar relative h-screen overflow-x-clip overflow-y-scroll bg-base-300 font-notoSans">
<nav class="navbar sticky top-0 isolate z-50 bg-base-100 px-4 font-sans">
<div class="navbar-start">
<tanoc-store-logo class="hidden place-items-center md:grid"></tanoc-store-logo>
</div>
<div class="navbar-center">
<h1 class="hidden md:inline-block">
<strong class="px-3 text-2xl">Tracked Items</strong>
</h1>
</div>
<div class="navbar-end">
<a class="btn btn-ghost" href="../settings/">Settings</a>
<currency-selector class="w-20"></currency-selector>
</div>
</nav>
<div class="flex items-center gap-2 p-2">
<button id="add-product-button" class="btn bg-base-100">Add Item</button>
<button id="export-products-button" class="btn bg-base-100">Export</button>
</div>
<main id="card-wrapper" class="my-8 grid w-full gap-5 px-[5%]"></main>
<product-search-modal></product-search-modal>
<alert-box class="fixed z-50"></alert-box>
</div>
<script type="module" src="main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,70 @@
import TanocStore from '@lib/tanocStore'
import CurrencyManager from '@lib/currency'
import ProductManager from '@lib/products'
/* Web Components */
import ProductCard from '@lib/components/productCard'
import TanocStoreLogo from '@lib/components/tanocStoreLogo'
import CurrencySelector from '@lib/components/currencySelector'
import ProductSearchModal from '@lib/components/productSearchModal'
import AlertBox, { AlertEvent } from '@lib/components/alertBox'
AlertBox.define()
ProductCard.define()
TanocStoreLogo.define()
CurrencySelector.define()
ProductSearchModal.define()
/* Web Components */
const productMap = new Map<string, ProductCard>()
const cardWrapper = document.querySelector<HTMLElement>('#card-wrapper')!
const alerBox = document.querySelector<AlertBox>('alert-box')!
const currencySelector = document.querySelector<CurrencySelector>('currency-selector')!
currencySelector.oncurrencychange = (event) => convertProductPrices(event.detail.newCurrencyCode, Array.from(productMap.values()))
const productSearchModal = document.querySelector<ProductSearchModal>('product-search-modal')!
productSearchModal.addEventListener('alert', ((event: AlertEvent) => alerBox.add(event.detail)) as EventListener)
productSearchModal.onadd = constructCard
const addProductButton = document.querySelector<HTMLButtonElement>('#add-product-button')!
addProductButton.onclick = productSearchModal.open
const exportProductsButton = document.querySelector<HTMLButtonElement>('#export-products-button')!
exportProductsButton.onclick = () => exportProducts(Array.from(productMap.values(), (card) => card.product))
function constructCard(product: Product): ProductCard {
const card = new ProductCard(product)
productMap.set(product.id, card)
cardWrapper.appendChild(card)
return card
}
async function convertProductPrices(currencyCode: string, products: ProductCard[]) {
const newCurrency = await CurrencyManager.getCurrency(currencyCode)
async function exchangeProductCurrency(productCard: ProductCard) {
const exchangeRate = await CurrencyManager.getExchangeRate(productCard.product.price.currency, currencyCode)
if (!newCurrency || !exchangeRate) throw Error('Failed to find exchange rate')
return productCard.convertCurrency(newCurrency, exchangeRate)
}
await Promise.all(products.map(exchangeProductCurrency))
}
function exportProducts(products: Product[]) {
const blob = new Blob([JSON.stringify({ products })], { type: 'text/plain' })
const objectURL = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectURL
link.download = 'products.json'
link.click()
URL.revokeObjectURL(objectURL)
link.remove()
}
/* Initialization */
ProductManager.products.forEach((id) => TanocStore.product(id).then(constructCard))

26
src/routes/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nicholas Tamassia - CS343 Project - Home</title>
<link rel="stylesheet" href="app.css" />
<script type="module">
import { loadTheme } from '@lib/themes'
loadTheme()
</script>
</head>
<body>
<div class="hero min-h-screen" style="background-image: url(@images/albumcollage.png)">
<div class="hero-overlay !bg-[#15191e] !bg-opacity-85"></div>
<div class="hero-content text-center text-neutral-content">
<div class="max-w-md">
<h1 class="mb-5 text-5xl font-bold">TANO*C Store Tracker</h1>
<p class="mb-5">A simple dashboard to keep track of the stock of items from TANO*C Store</p>
<a class="btn btn-primary" href="./dashboard/">Go to Dashboard</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nicholas Tamassia - CS343 Project - Settings</title>
<link rel="stylesheet" href="../app.css" />
<script type="module">
import { loadTheme } from '@lib/themes'
loadTheme()
</script>
</head>
<body>
<div class="no-scrollbar relative h-screen overflow-x-clip overflow-y-scroll bg-base-300 font-notoSans">
<nav class="navbar sticky top-0 isolate z-10 bg-base-100 px-4 font-sans">
<div class="navbar-start">
<tanoc-store-logo class="hidden place-items-center md:grid"></tanoc-store-logo>
</div>
<div class="navbar-center">
<h1 class="hidden md:inline-block">
<strong class="px-3 text-2xl">Settings</strong>
</h1>
</div>
<div class="navbar-end">
<a class="btn btn-ghost" href="../dashboard/">Dashboard</a>
</div>
</nav>
<main class="my-8 w-full px-[5%]">
<div class="mx-auto flex w-full max-w-screen-lg flex-col gap-8">
<div class="flex flex-col gap-2 lg:flex-row lg:gap-0">
<div class="card rounded-box bg-base-100 lg:flex-[1.5]">
<div class="card-body">
<h2 class="card-title">Currencies</h2>
<p>Enable and Disable the currencies you would like to be able to convert to</p>
<div id="currency-actions" class="card-actions justify-end">
<refresh-exchange-rates-button></refresh-exchange-rates-button>
</div>
</div>
</div>
<div class="lg:divider lg:divider-horizontal"></div>
<currency-table class="card h-80 overflow-x-auto rounded-box bg-base-100 lg:flex-1"></currency-table>
</div>
<div class="divider !my-0 lg:hidden"></div>
<div class="flex flex-col gap-2 lg:flex-row lg:gap-0">
<div class="card rounded-box bg-base-100 lg:flex-1">
<div class="card-body">
<h2 class="card-title">Customization</h2>
<p>Customize the interface to your heart's desire!</p>
</div>
</div>
<div class="lg:divider lg:divider-horizontal"></div>
<div class="card h-80 rounded-box bg-base-100 lg:flex-[1.5]">
<div class="card-body">
<fieldset class="join flex-wrap">
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Dark" value="dark" />
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Light" value="light" />
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Synthwave" value="synthwave" />
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Valentine" value="valentine" />
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Aqua" value="aqua" />
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Dracula" value="dracula" />
</fieldset>
</div>
</div>
</div>
</div>
</main>
<alert-box class="fixed z-50"></alert-box>
</div>
<script type="module" src="settings.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
import ThemeManager from '@lib/themes'
/* Web Components */
import AlertBox from '@lib/components/alertBox'
import CurrencyTable from '@lib/components/currencyTable'
import TanocStoreLogo from '@lib/components/tanocStoreLogo'
import RefreshExchangeRatesButton from '@lib/components/refreshExchangeRatesButton'
AlertBox.define()
CurrencyTable.define()
TanocStoreLogo.define()
RefreshExchangeRatesButton.define()
/* Web Components */
const alertBox = document.querySelector<AlertBox>('alert-box')!
const refreshExchangeRatesButton = document.querySelector<RefreshExchangeRatesButton>('refresh-exchange-rates-button')!
refreshExchangeRatesButton.onrefresh = () => alertBox.add({ message: 'Refreshed exchange rates', type: 'alert-success' })
refreshExchangeRatesButton.onfail = () => alertBox.add({ message: 'Failed to refresh exchange rates', type: 'alert-error' })
const themeButtons = document.querySelectorAll<HTMLInputElement>('input[name="theme-buttons"]')!
themeButtons.forEach((button) => {
if (button.value === ThemeManager.activeTheme) {
button.checked = true
}
button.onchange = () => {
if (button.checked && ThemeManager.validTheme(button.value)) {
ThemeManager.activeTheme = button.value
}
}
})

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CURRENCY_API_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

18
tailwind.config.js Normal file
View File

@@ -0,0 +1,18 @@
import daisyui from 'daisyui'
import * as defaultTheme from 'tailwindcss/defaultTheme'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,ts}'],
theme: {
extend: {
fontFamily: {
notoSans: ["'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'", ...defaultTheme.fontFamily.sans],
},
},
},
plugins: [daisyui],
daisyui: {
themes: true,
},
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"baseUrl": "./",
"paths": {
"@lib/*": ["src/lib/*"],
"@images/*": ["src/images/*"]
},
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "src/app.d.ts"]
}

32
vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { resolve } from 'path'
import { defineConfig } from 'vite'
const root = 'src/routes' // Entry point
export default defineConfig({
base: './', // Ensure all paths are relative
root,
resolve: {
alias: {
'@lib': resolve(__dirname, 'src/lib'),
'@images': resolve(__dirname, 'src/images'),
},
},
esbuild: {
supported: {
'top-level-await': true,
},
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, root, 'index.html'),
dashboard: resolve(__dirname, root, 'dashboard/index.html'),
settings: resolve(__dirname, root, 'settings/index.html'),
},
},
minify: false, // So professor can actually read the files
outDir: '../dist', // Build output to dist directory in src
},
envDir: '../../',
})