Initial Commit, final submission
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
8
.prettierrc
Normal 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
2665
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
19
src/app.d.ts
vendored
Normal file
19
src/app.d.ts
vendored
Normal 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
BIN
src/images/albumcollage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 MiB |
BIN
src/images/tanocStoreLogo.png
Normal file
BIN
src/images/tanocStoreLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
59
src/lib/components/alertBox.ts
Normal file
59
src/lib/components/alertBox.ts
Normal 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 }
|
||||
119
src/lib/components/currencySelector.ts
Normal file
119
src/lib/components/currencySelector.ts
Normal 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
|
||||
96
src/lib/components/currencyTable.ts
Normal file
96
src/lib/components/currencyTable.ts
Normal 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
|
||||
104
src/lib/components/productCard.ts
Normal file
104
src/lib/components/productCard.ts
Normal 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
|
||||
218
src/lib/components/productSearchModal.ts
Normal file
218
src/lib/components/productSearchModal.ts
Normal 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
|
||||
42
src/lib/components/refreshExchangeRatesButton.ts
Normal file
42
src/lib/components/refreshExchangeRatesButton.ts
Normal 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
|
||||
27
src/lib/components/tanocStoreLogo.ts
Normal file
27
src/lib/components/tanocStoreLogo.ts
Normal 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
179
src/lib/currency.ts
Normal 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
41
src/lib/products.ts
Normal 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
72
src/lib/tanocStore.ts
Normal 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
36
src/lib/themes.ts
Normal 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
54
src/routes/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/routes/dashboard/index.html
Normal file
40
src/routes/dashboard/index.html
Normal 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>
|
||||
70
src/routes/dashboard/main.ts
Normal file
70
src/routes/dashboard/main.ts
Normal 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
26
src/routes/index.html
Normal 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>
|
||||
72
src/routes/settings/index.html
Normal file
72
src/routes/settings/index.html
Normal 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>
|
||||
32
src/routes/settings/settings.ts
Normal file
32
src/routes/settings/settings.ts
Normal 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
9
src/vite-env.d.ts
vendored
Normal 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
18
tailwind.config.js
Normal 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
29
tsconfig.json
Normal 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
32
vite.config.ts
Normal 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: '../../',
|
||||
})
|
||||
Reference in New Issue
Block a user