Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: offer virtual cards by user location #270

Merged
merged 16 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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
19 changes: 14 additions & 5 deletions functions/list-gift-cards.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
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<Response> {
try {
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];

Expand All @@ -23,8 +30,10 @@ export async function onRequest(ctx: Context): Promise<Response> {
}
}

async function getGiftCards(productQuery: string, accessToken: AccessToken): Promise<GiftCard[]> {
const url = `${getBaseUrl(accessToken.isSandbox)}/products?productName=${productQuery}`;
async function getGiftCards(productQuery: string, country: string, accessToken: AccessToken): Promise<GiftCard[]> {
// 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",
Expand Down Expand Up @@ -53,5 +62,5 @@ async function getGiftCards(productQuery: string, accessToken: AccessToken): Pro
);
}

return (responseJson as ReloadlyListGiftCardResponse).content;
return responseJson as GiftCard[];
}
7 changes: 6 additions & 1 deletion functions/post-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +66,11 @@ export async function onRequest(ctx: Context): Promise<Response> {
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);
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -82,8 +83,7 @@
"lint-staged": {
"*.ts": [
"yarn prettier --write",
"eslint --fix",
"bash .github/workflows/scripts/kebab-case.sh"
EresDev marked this conversation as resolved.
Show resolved Hide resolved
"eslint --fix"
],
"src/**.{ts,json}": [
"cspell"
Expand All @@ -94,4 +94,4 @@
"@commitlint/config-conventional"
]
}
}
}
8 changes: 7 additions & 1 deletion shared/helpers.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
</div>
</a>
</header>
<div>
<div class="receipt-container">
<table class="receipt" data-details-visible="false" data-make-claim-rendered="false" data-contract-loaded="false" data-make-claim="error">
<thead>
<tr>
Expand Down
24 changes: 3 additions & 21 deletions static/scripts/rewards/gift-cards/gift-card.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -88,27 +88,9 @@ function getRangePricesHtml(giftCard: GiftCard, rewardAmount: BigNumberish) {
</div>
<div class="available">
<div>${formatEther(rewardAmount)}${giftCard.senderCurrencyCode}</div>
<div>${giftCardValue.toFixed(2)}${giftCard.recipientCurrencyCode}</div> </div
><br /><p>Also available in</p>`;
<div>${giftCardValue.toFixed(2)}${giftCard.recipientCurrencyCode}</div>
</div>`;
}

const priceToValueMap = getRangePriceToValueMap(giftCard);
const prices = Object.keys(priceToValueMap);

_html += html`
<div>
<div>Value</div>
<div title="${giftCard.minRecipientDenomination.toFixed(2)}-${giftCard.maxRecipientDenomination.toFixed(2)}${giftCard.recipientCurrencyCode}"
>${giftCard.minRecipientDenomination.toFixed(0)}-${giftCard.maxRecipientDenomination.toFixed(0)}${giftCard.recipientCurrencyCode}</div
>
</div>
<div>
<div>Price</div>
<div title="${Number(prices[0]).toFixed(2)}-${Number(prices[1]).toFixed(2)}${giftCard.senderCurrencyCode}"
>${Number(prices[0]).toFixed(0)}-${Number(prices[1]).toFixed(0)}${giftCard.senderCurrencyCode}</div
>
</div>
`;

return _html;
}
92 changes: 60 additions & 32 deletions static/scripts/rewards/gift-cards/list-gift-cards.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
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";
Expand All @@ -8,6 +8,7 @@ import { getApiBaseUrl } from "./helpers";
import { getGiftCardActivateInfoHtml } from "./activate/activate-html";
import { getGiftCardHtml } from "./gift-card";
import { getRedeemCodeHtml } from "./reveal/redeem-code-html";
import ct from "countries-and-timezones";

export async function initClaimGiftCard(app: AppState) {
const giftCardsSection = document.getElementById("gift-cards");
Expand All @@ -25,7 +26,10 @@ export async function initClaimGiftCard(app: AppState) {
activateInfoSection.innerHTML = "";

const retrieveOrderUrl = `${getApiBaseUrl()}/get-order?orderId=${getGiftCardOrderId(app.reward.beneficiary, app.reward.signature)}`;
const listGiftCardsUrl = `${getApiBaseUrl()}/list-gift-cards`;

const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const countries = ct.getCountriesForTimezone(localTimezone);
const listGiftCardsUrl = `${getApiBaseUrl()}/list-gift-cards?country=${countries[0].id}`;

rndquu marked this conversation as resolved.
Show resolved Hide resolved
const requestInit = {
method: "GET",
Expand All @@ -41,47 +45,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(`<h2 class="heading-gift-card">Your gift card</h2>`);
htmlParts.push(`<div class="gift-cards-wrapper purchased">`);
if (giftCard) {
htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount));
addPurchasedCardHtml(giftCard, transaction, app, giftCardsSection, activateInfoSection);
}
htmlParts.push(getRedeemCodeHtml(transaction));
htmlParts.push(`</div>`);
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 = "<p class='list-error'>There are no Visa/Mastercard available to claim at the moment.</p>";
} else {
giftCardsSection.innerHTML = "<p class='list-error'>There was a problem in fetching gift cards. Try again later.</p>";
}

activateInfoSection.innerHTML = activateInfoHtmlParts.join("");
attachActivateInfoAction();
}

attachRevealAction(transaction, app);
} else if (retrieveGiftCardsResponse.status == 200) {
const htmlParts: string[] = [];
htmlParts.push(`<h2 class="heading-gift-card">Or claim in virtual visa/mastercard</h2>`);
function addPurchasedCardHtml(
giftCard: GiftCard,
transaction: OrderTransaction,
app: AppState,
giftCardsSection: HTMLElement,
activateInfoSection: HTMLElement
) {
const htmlParts: string[] = [];
htmlParts.push(`<h2 class="heading-gift-card">Your gift card</h2>`);
htmlParts.push(`<div class="gift-cards-wrapper purchased">`);

htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount));

htmlParts.push(getRedeemCodeHtml(transaction));
htmlParts.push(`</div>`);

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(`<h2 class="heading-gift-card">Or claim in virtual visa/mastercard</h2>`);
if (giftCards.length) {
htmlParts.push(`<div class="gift-cards-wrapper${giftCards.length < 3 ? " center" : ""}">`);
giftCards.forEach((giftCard: GiftCard) => {
htmlParts.push(getGiftCardHtml(giftCard, app.reward.amount));
});
htmlParts.push(`</div>`);

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 = "<p class='list-error'>There are no Visa/Mastercard available to claim at the moment.</p>";
} else {
giftCardsSection.innerHTML = "<p class='list-error'>There was a problem in fetching gift cards. Try again later.</p>";
htmlParts.push(`<p class="list-error">There are no Visa/Mastercard available to claim at the moment.</p>`);
}

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);
}
7 changes: 0 additions & 7 deletions static/styles/rewards/gift-cards.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions static/styles/rewards/pay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -161,7 +165,6 @@ footer {
/* display: none; */
opacity: 0;
transition: opacity 1s;
/* padding-top: 80px; */
text-align: center;
}
#carousel > div {
Expand Down
36 changes: 33 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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==
Expand All @@ -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"
Expand Down
Loading