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 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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"useGitignore": true,
"language": "en",
"words": [
"adblocker",
"binkey",
"binsec",
"blockscan",
Expand Down
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;
}
33 changes: 33 additions & 0 deletions static/scripts/rewards/gift-cards/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
96 changes: 63 additions & 33 deletions static/scripts/rewards/gift-cards/list-gift-cards.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,8 +24,14 @@ export async function initClaimGiftCard(app: AppState) {
}
activateInfoSection.innerHTML = "";

const country = await getUserCountryCode();
if (!country) {
giftCardsSection.innerHTML = `<p class="list-error">Failed to load suitable virtual cards for you. Refresh or try disabling adblocker.</p>`;
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",
Expand All @@ -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(`<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
2 changes: 1 addition & 1 deletion wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Loading
Loading