Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/components/BookListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ const { formatPrice, formatDiscountedPrice } = useCurrency()

const pricingItem = computed(() => bookInfo.pricingItems.value[props.priceIndex])
const originalPrice = computed(() => pricingItem.value?.price || 0)
const formattedOriginalPrice = computed(() => formatPrice(originalPrice.value))
const priceCurrencyOverride = computed(() => pricingItem.value?.priceInDecimalByCurrency)
const formattedOriginalPrice = computed(() => formatPrice(originalPrice.value, priceCurrencyOverride.value))
const formattedDiscountedPrice = computed(() => {
if (isLikerPlus.value && originalPrice.value > 0) {
return formatDiscountedPrice(originalPrice.value, PLUS_BOOK_PURCHASE_DISCOUNT)
return formatDiscountedPrice(originalPrice.value, PLUS_BOOK_PURCHASE_DISCOUNT, priceCurrencyOverride.value)
}
return null
})
Expand Down
9 changes: 7 additions & 2 deletions app/components/BookstoreItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,16 @@ const bookName = computed(() => bookInfo.name.value || props.bookName)
const authorName = computed(() => bookInfo.authorName.value)

const price = computed(() => props.price || bookInfo.minPrice.value)
const formattedPrice = computed(() => formatPrice(price.value))
// Only the catalog's own min-tier carries an override; a price passed in via
// props has no currency override context, so fall back to ladder conversion.
const priceCurrencyOverride = computed(() => (
props.price ? undefined : bookInfo.minPricingItem.value?.priceInDecimalByCurrency
))
const formattedPrice = computed(() => formatPrice(price.value, priceCurrencyOverride.value))

const formattedDiscountPrice = computed(() => {
if (isLikerPlus.value && price.value > 0) {
return formatDiscountedPrice(price.value, PLUS_BOOK_PURCHASE_DISCOUNT)
return formatDiscountedPrice(price.value, PLUS_BOOK_PURCHASE_DISCOUNT, priceCurrencyOverride.value)
}
return null
})
Expand Down
10 changes: 7 additions & 3 deletions app/composables/use-book-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export default function (
name: localeString(item.name),
description: localeString(item.description),
price: item.price,
priceInDecimalByCurrency: item.priceInDecimalByCurrency,
currency: item.price > 0 ? 'USD' : '',
isSoldOut: item.isSoldOut,
canTip: item.isAllowCustomPrice && item.isTippingEnabled,
Expand All @@ -263,11 +264,13 @@ export default function (
})
})

const minPrice = computed(() => {
if (!pricingItems.value.length) return 0
return Math.min(...pricingItems.value.map(item => item.price))
const minPricingItem = computed(() => {
if (!pricingItems.value.length) return undefined
return pricingItems.value.reduce((min, item) => (item.price < min.price ? item : min))
})

const minPrice = computed(() => minPricingItem.value?.price ?? 0)

const userOwnedNFTIds = computed(() => {
return bookshelfStore.getTokenIdsByNFTClassId(toValue(nftClassId))
})
Expand Down Expand Up @@ -364,6 +367,7 @@ export default function (
promotionalVideos,

pricingItems,
minPricingItem,
minPrice,

userOwnedNFTIds,
Expand Down
44 changes: 36 additions & 8 deletions app/composables/use-currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,52 @@ export default function () {
}).format(amount)}`
}

function formatPrice(price: number) {
const convertedPrice = convertUSDPriceToCurrency(price, displayCurrency.value)
return formatCurrencyAmount(convertedPrice, displayCurrency.value)
// A per-book override (minor units, e.g. cents) takes precedence over the
// index-based ladder conversion. USD always uses the ladder/stored price.
function resolvePrice(
usdPrice: number,
priceInDecimalByCurrency?: BookPriceInDecimalByCurrency,
): number {
const currency = displayCurrency.value
if (currency !== 'usd') {
const override = priceInDecimalByCurrency?.[currency]
if (typeof override === 'number' && override > 0) {
return override / 100
}
}
return convertUSDPriceToCurrency(usdPrice, currency)
}

function formatPrice(price: number, priceInDecimalByCurrency?: BookPriceInDecimalByCurrency) {
return formatCurrencyAmount(resolvePrice(price, priceInDecimalByCurrency), displayCurrency.value)
}

function formatDiscountedPrice(usdPrice: number, discountRate: number): string {
const convertedPrice = convertUSDPriceToCurrency(usdPrice, displayCurrency.value)
const discountedPrice = convertedPrice * (1 - discountRate)
function formatDiscountedPrice(
usdPrice: number,
discountRate: number,
priceInDecimalByCurrency?: BookPriceInDecimalByCurrency,
): string {
const discountedPrice = resolvePrice(usdPrice, priceInDecimalByCurrency) * (1 - discountRate)
return formatCurrencyAmount(discountedPrice, displayCurrency.value)
}

function convertPrice(usdPrice: number): number {
return convertUSDPriceToCurrency(usdPrice, displayCurrency.value)
function convertPrice(
usdPrice: number,
priceInDecimalByCurrency?: BookPriceInDecimalByCurrency,
): number {
return resolvePrice(usdPrice, priceInDecimalByCurrency)
}

// Formats an amount already expressed in the display currency (e.g. a sum of
// per-line-item resolved prices), without re-applying ladder conversion.
function formatConvertedPrice(amount: number): string {
return formatCurrencyAmount(amount, displayCurrency.value)
}

return {
formatPrice,
formatDiscountedPrice,
convertPrice,
formatConvertedPrice,
}
}
24 changes: 20 additions & 4 deletions app/pages/checkout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

<ul class="space-y-4">
<li
v-for="item in cartItems"
v-for="item in cartLineItems"
:key="`${item.classId}-${item.priceIndex}`"
class="flex gap-4 p-4 border border-gray-200 rounded-lg"
>
Expand Down Expand Up @@ -78,7 +78,7 @@
/>
<span
class="text-green-600 font-semibold"
v-text="formatPrice((item.pricingItem?.price || 0) * item.quantity)"
v-text="formatConvertedPrice(item.convertedLineTotal)"
/>
</div>
</div>
Expand Down Expand Up @@ -108,7 +108,7 @@
<span v-text="$t('checkout_total_label')" />
<span
class="text-green-600"
v-text="formatPrice(totalPrice)"
v-text="formatConvertedPrice(totalPriceConverted)"
/>
</div>
</div>
Expand Down Expand Up @@ -175,7 +175,7 @@ const getRouteQuery = useRouteQuery()
const { user } = useUserSession()
const nftStore = useNFTStore()
const bookstoreStore = useBookstoreStore()
const { formatPrice } = useCurrency()
const { convertPrice, formatConvertedPrice } = useCurrency()
const { getCheckoutCurrency } = usePaymentCurrency()
const { handleError } = useErrorHandler()
const { getAnalyticsParameters } = useAnalytics()
Expand Down Expand Up @@ -271,6 +271,22 @@ const totalPrice = computed(() => {
}, 0)
})

// Per-line totals resolved once in the buyer's currency (honouring any
// per-tier override), so the line rows and the grand total cannot diverge.
// `totalPrice` (USD) is kept for analytics where currency is reported as USD.
const cartLineItems = computed(() => cartItems.value.map(item => ({
...item,
convertedLineTotal: convertPrice(
item.pricingItem?.price || 0,
item.pricingItem?.priceInDecimalByCurrency,
) * item.quantity,
})))

const totalPriceConverted = computed(() => cartLineItems.value.reduce(
(total, item) => total + item.convertedLineTotal,
0,
))

const eventPayload = computed(() => {
const payload = {
currency: 'USD',
Expand Down
4 changes: 2 additions & 2 deletions app/pages/store/[nftClassId]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
<ExpandableContent>
<div
class="markdown"
v-html="bookInfoDescriptionHTML"

Check warning on line 145 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 145 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>
</ExpandableContent>
<template v-if="bookInfo.descriptionSummary?.value">
Expand All @@ -152,7 +152,7 @@
<ExpandableContent>
<div
class="markdown"
v-html="bookInfoDescriptionSummaryHTML"

Check warning on line 155 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 155 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>
</ExpandableContent>
</template>
Expand Down Expand Up @@ -244,7 +244,7 @@
<ExpandableContent>
<div
class="markdown"
v-html="previewContentHTML"

Check warning on line 247 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 247 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>
</ExpandableContent>
</template>
Expand All @@ -264,7 +264,7 @@
<ExpandableContent>
<div
class="markdown"
v-html="authorDescriptionHTML"

Check warning on line 267 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 267 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>
</ExpandableContent>
</template>
Expand All @@ -273,7 +273,7 @@
<ExpandableContent>
<div
class="markdown"
v-html="tableOfContentsHTML"

Check warning on line 276 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 276 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>
</ExpandableContent>
</template>
Expand Down Expand Up @@ -579,7 +579,7 @@
<div
v-if="item.renderedDescription"
class="markdown whitespace-normal text-left mt-2"
v-html="item.renderedDescription"

Check warning on line 582 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack

Check warning on line 582 in app/pages/store/[nftClassId]/index.vue

View workflow job for this annotation

GitHub Actions / build

'v-html' directive can lead to XSS attack
/>

<div class="flex flex-wrap gap-1 mt-3">
Expand Down Expand Up @@ -1281,8 +1281,8 @@
return {
...item,
label: item.isAutoDeliver ? item.name : $t('product_page_edition_title', { name: item.name }),
originalPrice: formatPrice(item.price),
discountedPrice: shouldShowDiscount ? formatDiscountedPrice(item.price, PLUS_BOOK_PURCHASE_DISCOUNT) : null,
originalPrice: formatPrice(item.price, item.priceInDecimalByCurrency),
discountedPrice: shouldShowDiscount ? formatDiscountedPrice(item.price, PLUS_BOOK_PURCHASE_DISCOUNT, item.priceInDecimalByCurrency) : null,
isSelected: index === selectedPricingItemIndex.value,
renderedDescription: renderDescription(item.description || ''),
}
Expand Down
8 changes: 8 additions & 0 deletions shared/types/bookstore.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,17 @@ declare global {
mayHaveMore?: boolean
}

// Per-currency price overrides from the API, in that currency's minor units
// (e.g. cents). A missing currency falls back to ladder conversion.
interface BookPriceInDecimalByCurrency {
hkd?: number
twd?: number
}

interface BookstorePrice {
index: number
price: number
priceInDecimalByCurrency?: BookPriceInDecimalByCurrency
name: {
en: string
zh: string
Expand Down
Loading