diff --git a/.cspell.json b/.cspell.json index 1de3c234..a89f082e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -5,6 +5,7 @@ "useGitignore": true, "language": "en", "words": [ + "adblocker", "binkey", "binsec", "blockscan", diff --git a/functions/list-gift-cards.ts b/functions/list-gift-cards.ts index 1446d82f..42726eb7 100644 --- a/functions/list-gift-cards.ts +++ b/functions/list-gift-cards.ts @@ -1,6 +1,6 @@ import { GiftCard } from "../shared/types"; import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers"; -import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyListGiftCardResponse } from "./types"; +import { AccessToken, Context, ReloadlyFailureResponse } from "./types"; import { validateEnvVars, validateRequestMethod } from "./validators"; export async function onRequest(ctx: Context): Promise { @@ -8,8 +8,15 @@ export async function onRequest(ctx: Context): Promise { validateRequestMethod(ctx.request.method, "GET"); validateEnvVars(ctx); + const { searchParams } = new URL(ctx.request.url); + const country = searchParams.get("country"); + + if (!country) { + throw new Error(`Invalid query parameters: ${{ country }}`); + } + const accessToken = await getAccessToken(ctx.env); - const [masterCards, visaCards] = await Promise.all([getGiftCards("mastercard", accessToken), getGiftCards("visa", accessToken)]); + const [masterCards, visaCards] = await Promise.all([getGiftCards("mastercard", country, accessToken), getGiftCards("visa", country, accessToken)]); const giftCards = [...masterCards, ...visaCards]; @@ -23,8 +30,10 @@ export async function onRequest(ctx: Context): Promise { } } -async function getGiftCards(productQuery: string, accessToken: AccessToken): Promise { - const url = `${getBaseUrl(accessToken.isSandbox)}/products?productName=${productQuery}`; +async function getGiftCards(productQuery: string, country: string, accessToken: AccessToken): Promise { + // productCategoryId = 1 = Finance. + // This should prevent mixing of other gift cards with similar keywords + const url = `${getBaseUrl(accessToken.isSandbox)}/countries/${country}/products?productName=${productQuery}&productCategoryId=1`; console.log(`Retrieving gift cards from ${url}`); const options = { method: "GET", @@ -53,5 +62,5 @@ async function getGiftCards(productQuery: string, accessToken: AccessToken): Pro ); } - return (responseJson as ReloadlyListGiftCardResponse).content; + return responseJson as GiftCard[]; } diff --git a/functions/post-order.ts b/functions/post-order.ts index df7bd97d..f2f59154 100644 --- a/functions/post-order.ts +++ b/functions/post-order.ts @@ -2,7 +2,7 @@ import { TransactionReceipt, TransactionResponse } from "@ethersproject/provider import { JsonRpcProvider } from "@ethersproject/providers/lib/json-rpc-provider"; import { Interface, TransactionDescription } from "ethers/lib/utils"; import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address } from "../shared/constants"; -import { getFastestRpcUrl, getGiftCardOrderId } from "../shared/helpers"; +import { getFastestRpcUrl, getGiftCardOrderId, isGiftCardAvailable } from "../shared/helpers"; import { getGiftCardValue, isClaimableForAmount } from "../shared/pricing"; import { ExchangeRate, GiftCard, OrderRequestParams } from "../shared/types"; import { permit2Abi } from "../static/scripts/rewards/abis/permit2-abi"; @@ -66,6 +66,11 @@ export async function onRequest(ctx: Context): Promise { const exchangeRateResponse = await getExchangeRate(1, giftCard.recipientCurrencyCode, accessToken); exchangeRate = exchangeRateResponse.senderAmount; } + + if (!isGiftCardAvailable(giftCard, amountDaiWei)) { + throw new Error(`The ordered gift card does not meet available criteria: ${JSON.stringify(giftCard)}`); + } + const giftCardValue = getGiftCardValue(giftCard, amountDaiWei, exchangeRate); const orderId = getGiftCardOrderId(txReceipt.from, txParsed.args.signature); diff --git a/package.json b/package.json index d6d7ba99..0ad8727b 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@supabase/supabase-js": "^2.44.4", "@ubiquibot/permit-generation": "^1.4.1", "@ubiquity-dao/rpc-handler": "^1.1.0", + "countries-and-timezones": "^3.6.0", "dotenv": "^16.4.4", "ethers": "^5.7.2", "npm-run-all": "^4.1.5" @@ -82,8 +83,7 @@ "lint-staged": { "*.ts": [ "yarn prettier --write", - "eslint --fix", - "bash .github/workflows/scripts/kebab-case.sh" + "eslint --fix" ], "src/**.{ts,json}": [ "cspell" @@ -94,4 +94,4 @@ "@commitlint/config-conventional" ] } -} \ No newline at end of file +} diff --git a/shared/helpers.ts b/shared/helpers.ts index 76ab3609..34fb0679 100644 --- a/shared/helpers.ts +++ b/shared/helpers.ts @@ -1,5 +1,7 @@ -import { ethers } from "ethers"; +import { BigNumberish, ethers } from "ethers"; import { RPCHandler } from "@ubiquity-dao/rpc-handler"; +import { GiftCard } from "./types"; +import { isRangePriceGiftCardClaimable } from "./pricing"; export function getGiftCardOrderId(rewardToAddress: string, signature: string) { const checksumAddress = ethers.utils.getAddress(rewardToAddress); @@ -30,3 +32,7 @@ export async function getFastestRpcUrl(networkId: string | number) { const provider = await handler.getFastestRpcProvider(); return provider.connection.url; } + +export function isGiftCardAvailable(giftCard: GiftCard, reward: BigNumberish): boolean { + return giftCard.denominationType == "RANGE" && isRangePriceGiftCardClaimable(giftCard, reward); +} diff --git a/static/index.html b/static/index.html index 5c936057..17f393b0 100644 --- a/static/index.html +++ b/static/index.html @@ -55,7 +55,7 @@ -
+
diff --git a/static/scripts/rewards/gift-cards/gift-card.ts b/static/scripts/rewards/gift-cards/gift-card.ts index cae42394..3294a8d2 100644 --- a/static/scripts/rewards/gift-cards/gift-card.ts +++ b/static/scripts/rewards/gift-cards/gift-card.ts @@ -1,6 +1,6 @@ import { BigNumberish } from "ethers"; import { GiftCard } from "../../../../shared/types"; -import { getFixedPriceToValueMap, getGiftCardValue, getRangePriceToValueMap, isRangePriceGiftCardClaimable } from "../../../../shared/pricing"; +import { getFixedPriceToValueMap, getGiftCardValue, isRangePriceGiftCardClaimable } from "../../../../shared/pricing"; import { formatEther } from "ethers/lib/utils"; const html = String.raw; @@ -88,27 +88,9 @@ function getRangePricesHtml(giftCard: GiftCard, rewardAmount: BigNumberish) {
${formatEther(rewardAmount)}${giftCard.senderCurrencyCode}
-
${giftCardValue.toFixed(2)}${giftCard.recipientCurrencyCode}

Also available in

`; +
${giftCardValue.toFixed(2)}${giftCard.recipientCurrencyCode}
+ `; } - const priceToValueMap = getRangePriceToValueMap(giftCard); - const prices = Object.keys(priceToValueMap); - - _html += html` -
-
Value
-
${giftCard.minRecipientDenomination.toFixed(0)}-${giftCard.maxRecipientDenomination.toFixed(0)}${giftCard.recipientCurrencyCode}
-
-
-
Price
-
${Number(prices[0]).toFixed(0)}-${Number(prices[1]).toFixed(0)}${giftCard.senderCurrencyCode}
-
- `; - return _html; } diff --git a/static/scripts/rewards/gift-cards/helpers.ts b/static/scripts/rewards/gift-cards/helpers.ts index 282f8a50..2ed223d7 100644 --- a/static/scripts/rewards/gift-cards/helpers.ts +++ b/static/scripts/rewards/gift-cards/helpers.ts @@ -1,4 +1,37 @@ +import ct from "countries-and-timezones"; + export function getApiBaseUrl() { // specify when backend functions and frontend are deployed to a different URL return ""; } + +async function getCountryCodeByIp() { + try { + const response = await fetch("https://ipinfo.io/json"); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + const json = await response.json(); + return json.country; + } catch (error) { + console.error(error); + return null; + } +} + +async function getCountryCodeByTimezone() { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const countries = ct.getCountriesForTimezone(localTimezone); + return countries[0]?.id; +} + +export async function getUserCountryCode() { + const methods = [getCountryCodeByIp, getCountryCodeByTimezone]; + for (let i = 0; i < methods.length; ++i) { + const countryCode = await methods[i](); + if (countryCode) { + return countryCode; + } + } + return null; +} diff --git a/static/scripts/rewards/gift-cards/list-gift-cards.ts b/static/scripts/rewards/gift-cards/list-gift-cards.ts index 6f20b8d7..3b0d3f14 100644 --- a/static/scripts/rewards/gift-cards/list-gift-cards.ts +++ b/static/scripts/rewards/gift-cards/list-gift-cards.ts @@ -1,10 +1,10 @@ -import { getGiftCardOrderId } from "../../../../shared/helpers"; +import { isGiftCardAvailable, getGiftCardOrderId } from "../../../../shared/helpers"; import { GiftCard, OrderTransaction } from "../../../../shared/types"; import { AppState } from "../app-state"; import { attachActivateInfoAction } from "./activate/activate-action"; import { attachClaimAction } from "./claim/claim-action"; import { attachRevealAction } from "./reveal/reveal-action"; -import { getApiBaseUrl } from "./helpers"; +import { getApiBaseUrl, getUserCountryCode } from "./helpers"; import { getGiftCardActivateInfoHtml } from "./activate/activate-html"; import { getGiftCardHtml } from "./gift-card"; import { getRedeemCodeHtml } from "./reveal/redeem-code-html"; @@ -24,8 +24,14 @@ export async function initClaimGiftCard(app: AppState) { } activateInfoSection.innerHTML = ""; + const country = await getUserCountryCode(); + if (!country) { + giftCardsSection.innerHTML = `

Failed to load suitable virtual cards for you. Refresh or try disabling adblocker.

`; + return; + } + const retrieveOrderUrl = `${getApiBaseUrl()}/get-order?orderId=${getGiftCardOrderId(app.reward.beneficiary, app.reward.signature)}`; - const listGiftCardsUrl = `${getApiBaseUrl()}/list-gift-cards`; + const listGiftCardsUrl = `${getApiBaseUrl()}/list-gift-cards?country=${country}`; const requestInit = { method: "GET", @@ -41,47 +47,71 @@ export async function initClaimGiftCard(app: AppState) { if (retrieveOrderResponse.status == 200) { const giftCard = giftCards.find((giftCard) => transaction.product.productId == giftCard.productId); - const htmlParts: string[] = []; - htmlParts.push(`

Your gift card

`); - htmlParts.push(`
`); if (giftCard) { - htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount)); + addPurchasedCardHtml(giftCard, transaction, app, giftCardsSection, activateInfoSection); } - htmlParts.push(getRedeemCodeHtml(transaction)); - htmlParts.push(`
`); - giftCardsSection.innerHTML = htmlParts.join(","); + } else if (retrieveGiftCardsResponse.status == 200) { + const availableGiftCards = giftCards.filter((giftCard: GiftCard) => { + return giftCard && isGiftCardAvailable(giftCard, app.reward.amount); + }); - const activateInfoHtmlParts: string[] = []; - if (giftCard) { - activateInfoHtmlParts.push(getGiftCardActivateInfoHtml(giftCard)); - } + addAvailableCardsHtml(availableGiftCards, app, giftCardsSection, activateInfoSection); + } else if (retrieveGiftCardsResponse.status == 404) { + giftCardsSection.innerHTML = "

There are no Visa/Mastercard available to claim at the moment.

"; + } else { + giftCardsSection.innerHTML = "

There was a problem in fetching gift cards. Try again later.

"; + } - activateInfoSection.innerHTML = activateInfoHtmlParts.join(""); + attachActivateInfoAction(); +} - attachRevealAction(transaction, app); - } else if (retrieveGiftCardsResponse.status == 200) { - const htmlParts: string[] = []; - htmlParts.push(`

Or claim in virtual visa/mastercard

`); +function addPurchasedCardHtml( + giftCard: GiftCard, + transaction: OrderTransaction, + app: AppState, + giftCardsSection: HTMLElement, + activateInfoSection: HTMLElement +) { + const htmlParts: string[] = []; + htmlParts.push(`

Your gift card

`); + htmlParts.push(`
`); + + htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount)); + + htmlParts.push(getRedeemCodeHtml(transaction)); + htmlParts.push(`
`); + + giftCardsSection.innerHTML = htmlParts.join(""); + + const activateInfoHtmlParts: string[] = []; + + activateInfoHtmlParts.push(getGiftCardActivateInfoHtml(giftCard)); + + activateInfoSection.innerHTML = activateInfoHtmlParts.join(""); + + attachRevealAction(transaction, app); +} + +function addAvailableCardsHtml(giftCards: GiftCard[], app: AppState, giftCardsSection: HTMLElement, activateInfoSection: HTMLElement) { + const htmlParts: string[] = []; + htmlParts.push(`

Or claim in virtual visa/mastercard

`); + if (giftCards.length) { htmlParts.push(`
`); giftCards.forEach((giftCard: GiftCard) => { htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount)); }); htmlParts.push(`
`); - - giftCardsSection.innerHTML = htmlParts.join(""); - - const activateInfoHtmlParts: string[] = []; - giftCards.forEach((giftCard: GiftCard) => { - activateInfoHtmlParts.push(getGiftCardActivateInfoHtml(giftCard)); - }); - activateInfoSection.innerHTML = activateInfoHtmlParts.join(""); - - attachClaimAction("claim-gift-card-btn", giftCards, app); - } else if (retrieveGiftCardsResponse.status == 404) { - giftCardsSection.innerHTML = "

There are no Visa/Mastercard available to claim at the moment.

"; } else { - giftCardsSection.innerHTML = "

There was a problem in fetching gift cards. Try again later.

"; + htmlParts.push(`

There are no Visa/Mastercard available to claim at the moment.

`); } - attachActivateInfoAction(); + giftCardsSection.innerHTML = htmlParts.join(""); + + const activateInfoHtmlParts: string[] = []; + giftCards.forEach((giftCard: GiftCard) => { + activateInfoHtmlParts.push(getGiftCardActivateInfoHtml(giftCard)); + }); + activateInfoSection.innerHTML = activateInfoHtmlParts.join(""); + + attachClaimAction("claim-gift-card-btn", giftCards, app); } diff --git a/static/styles/rewards/gift-cards.css b/static/styles/rewards/gift-cards.css index 6d4d72df..329cefe1 100644 --- a/static/styles/rewards/gift-cards.css +++ b/static/styles/rewards/gift-cards.css @@ -15,13 +15,6 @@ text-align: center; } -main:has(#gift-cards .gift-card) { - height: auto; -} - -main:has(#gift-cards .gift-card) div:has(table.receipt) { - padding-top: 50px; -} .redeem-info-wrapper[data-show="false"] { display: none; } diff --git a/static/styles/rewards/pay.css b/static/styles/rewards/pay.css index ed0ee90f..15f8684f 100644 --- a/static/styles/rewards/pay.css +++ b/static/styles/rewards/pay.css @@ -136,7 +136,11 @@ header a:hover #logo { main { display: flex; flex-direction: column; - height: 100vh; /* adjust this according to your needs */ +} + +main div.receipt-container { + height: 120px; + margin-bottom: 32px; } header { @@ -161,7 +165,6 @@ footer { /* display: none; */ opacity: 0; transition: opacity 1s; - /* padding-top: 80px; */ text-align: center; } #carousel > div { diff --git a/wrangler.toml b/wrangler.toml index bfa42f37..2a55a33f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,6 +2,6 @@ compatibility_flags = [ "nodejs_compat" ] compatibility_date = "2024-04-05" [vars] -USE_RELOADLY_SANDBOX = true +USE_RELOADLY_SANDBOX = "true" RELOADLY_API_CLIENT_ID = "" RELOADLY_API_CLIENT_SECRET = "" \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3816e840..9c75a508 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2856,6 +2856,11 @@ cosmiconfig@^8.3.6: parse-json "^5.2.0" path-type "^4.0.0" +countries-and-timezones@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/countries-and-timezones/-/countries-and-timezones-3.6.0.tgz#80716f05ebef270842fd72010f9b33e2848e24c7" + integrity sha512-8/nHBCs1eKeQ1jnsZVGdqrLYxS8nPcfJn8PnmxdJXWRLZdXsGFR8gnVhRjatGDBjqmPm7H+FtYpBYTPWd0Eiqg== + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -6352,7 +6357,16 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6429,7 +6443,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7018,7 +7039,7 @@ wrangler@^3.51.2: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7036,6 +7057,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"