diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 493b5fa9..b5153355 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb( prob: number, outcome: 'YES' | 'NO' ) { + if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity if (outcome === 'NO') prob = 1 - prob // First, find an upper bound that leads to a more extreme probability than prob. diff --git a/common/categories.ts b/common/categories.ts index 232aa526..f302e3f2 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,6 +1,7 @@ import { difference } from 'lodash' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' + export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [ 'manifold', 'personal', 'covid', - 'culture', 'gaming', 'crypto', - 'world', ] export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) + +export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({ + slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX, + name: CATEGORIES[c as category], +})) diff --git a/common/contract.ts b/common/contract.ts index 5ddcf0b8..177af862 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,6 +1,7 @@ import { Answer } from './answer' import { Fees } from './fees' import { JSONContent } from '@tiptap/core' +import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric @@ -46,8 +47,10 @@ export type Contract<T extends AnyContractType = AnyContractType> = { collectedFees: Fees groupSlugs?: string[] + groupLinks?: GroupLink[] uniqueBettorIds?: string[] uniqueBettorCount?: number + popularityScore?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/envs/prod.ts b/common/envs/prod.ts index a5506b6e..2cad4203 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -23,6 +23,7 @@ export type EnvConfig = { // Currency controls fixedAnte?: number startingBalance?: number + referralBonus?: number } type FirebaseConfig = { diff --git a/common/group.ts b/common/group.ts index 15348d5a..7d3215ae 100644 --- a/common/group.ts +++ b/common/group.ts @@ -11,8 +11,19 @@ export type Group = { contractIds: string[] chatDisabled?: boolean + mostRecentChatActivityTime?: number + mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] +export const GROUP_CHAT_SLUG = 'chat' + +export type GroupLink = { + slug: string + name: string + groupId: string + createdTime: number + userId?: string +} diff --git a/common/new-bet.ts b/common/new-bet.ts index f484b9f7..1f5c0340 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,4 @@ -import { sortBy, sumBy } from 'lodash' +import { sortBy, sum, sumBy } from 'lodash' import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { @@ -142,6 +142,13 @@ export const computeFills = ( limitProb: number | undefined, unfilledBets: LimitBet[] ) => { + if (isNaN(betAmount)) { + throw new Error('Invalid bet amount: ${betAmount}') + } + if (isNaN(limitProb ?? 0)) { + throw new Error('Invalid limitProb: ${limitProb}') + } + const sortedBets = sortBy( unfilledBets.filter((bet) => bet.outcome !== outcome), (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), @@ -239,6 +246,32 @@ export const getBinaryCpmmBetInfo = ( } } +export const getBinaryBetStats = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number, + unfilledBets: LimitBet[] +) => { + const { newBet } = getBinaryCpmmBetInfo( + outcome, + betAmount ?? 0, + contract, + limitProb, + unfilledBets as LimitBet[] + ) + const remainingMatched = + ((newBet.orderAmount ?? 0) - newBet.amount) / + (outcome === 'YES' ? limitProb : 1 - limitProb) + const currentPayout = newBet.shares + remainingMatched + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + + const totalFees = sum(Object.values(newBet.fees)) + + return { currentPayout, currentReturn, totalFees, newBet } +} + export const getNewBinaryDpmBetInfo = ( outcome: 'YES' | 'NO', amount: number, diff --git a/common/notification.ts b/common/notification.ts index 63a44a52..5fd4236b 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -63,3 +63,4 @@ export type notification_reason_types = | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' + | 'user_joined_from_your_group_invite' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 46885668..f399aa5a 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' -export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 +export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index c99e670f..ca62a80e 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -16,8 +16,8 @@ export const getMappedValue = const { min, max, isLogScale } = contract if (isLogScale) { - const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + const logValue = p * Math.log10(max - min + 1) + return 10 ** logValue + min - 1 } return p * (max - min) + min @@ -37,8 +37,11 @@ export const getPseudoProbability = ( max: number, isLogScale = false ) => { + if (value < min) return 0 + if (value > max) return 1 + if (isLogScale) { - return Math.log10(value - min) / Math.log10(max - min) + return Math.log10(value - min + 1) / Math.log10(max - min + 1) } return (value - min) / (max - min) diff --git a/common/user.ts b/common/user.ts index 477139fd..0dac5a19 100644 --- a/common/user.ts +++ b/common/user.ts @@ -38,12 +38,14 @@ export type User = { referredByUserId?: string referredByContractId?: string + referredByGroupId?: string + lastPingTime?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 -export const REFERRAL_AMOUNT = 500 +export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -57,7 +59,6 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types - lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' @@ -69,3 +70,6 @@ export type PortfolioMetrics = { timestamp: number userId: string } + +export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' diff --git a/common/util/format.ts b/common/util/format.ts index decdd55d..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,18 +33,24 @@ export function formatPercent(zeroToOne: number) { return (zeroToOne * 100).toFixed(decimalPlaces) + '%' } +const showPrecision = (x: number, sigfigs: number) => + // convert back to number for weird formatting reason + `${Number(x.toPrecision(sigfigs))}` + // Eg 1234567.89 => 1.23M; 5678 => 5.68K export function formatLargeNumber(num: number, sigfigs = 2): string { const absNum = Math.abs(num) - if (absNum < 1000) { - return '' + Number(num.toPrecision(sigfigs)) - } + if (absNum < 1) return showPrecision(num, sigfigs) + + if (absNum < 100) return showPrecision(num, 2) + if (absNum < 1000) return showPrecision(num, 3) + if (absNum < 10000) return showPrecision(num, 4) const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] - const suffixIdx = Math.floor(Math.log10(absNum) / 3) - const suffixStr = suffix[suffixIdx] - const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs) - return `${Number(numStr)}${suffixStr}` + const i = Math.floor(Math.log10(absNum) / 3) + + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/common/util/parse.ts b/common/util/parse.ts index 94b5ab7f..cdaa6a6c 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text' // other tiptap extensions import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' +import Iframe from './tiptap-iframe' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -49,6 +50,16 @@ export function parseWordsAsTags(text: string) { return parseTags(taggedText) } +// TODO: fuzzy matching +export const wordIn = (word: string, corpus: string) => + corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) + +const checkAgainstQuery = (query: string, corpus: string) => + query.split(' ').every((word) => wordIn(word, corpus)) + +export const searchInAny = (query: string, ...fields: string[]) => + fields.some((field) => checkAgainstQuery(query, field)) + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, @@ -70,6 +81,7 @@ export const exhibitExts = [ Image, Link, + Iframe, ] // export const exhibitExts = [StarterKit as unknown as Extension, Image] diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts new file mode 100644 index 00000000..5af63d2f --- /dev/null +++ b/common/util/tiptap-iframe.ts @@ -0,0 +1,92 @@ +// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts + +import { Node } from '@tiptap/core' + +export interface IframeOptions { + allowFullscreen: boolean + HTMLAttributes: { + [key: string]: any + } +} + +declare module '@tiptap/core' { + interface Commands<ReturnType> { + iframe: { + setIframe: (options: { src: string }) => ReturnType + } + } +} + +// These classes style the outer wrapper and the inner iframe; +// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue +const wrapperClasses = 'relative h-auto w-full overflow-hidden' +const iframeClasses = 'absolute top-0 left-0 h-full w-full' + +export default Node.create<IframeOptions>({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + ' ' + wrapperClasses, + // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: + style: 'padding-bottom: 20rem;', + }, + } + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameborder: { + default: 0, + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen, + }, + } + }, + + parseHTML() { + return [{ tag: 'iframe' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + this.options.HTMLAttributes, + [ + 'iframe', + { + ...HTMLAttributes, + class: HTMLAttributes.class + ' ' + iframeClasses, + }, + ], + ] + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/docs/docs/api.md b/docs/docs/api.md index 1cea6027..667c68b8 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -34,6 +34,18 @@ response was a 4xx or 5xx.) ## Endpoints +### `GET /v0/user/[username]` + +Gets a user by their username. Remember that usernames may change. + +Requires no authorization. + +### `GET /v0/user/by-id/[id]` + +Gets a user by their unique ID. Many other API endpoints return this as the `userId`. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -627,6 +639,7 @@ Requires no authorization. ## Changelog +- 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints - 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition diff --git a/firestore.rules b/firestore.rules index bd059f6a..0f28ca80 100644 --- a/firestore.rules +++ b/firestore.rules @@ -6,7 +6,12 @@ service cloud.firestore { match /databases/{database}/documents { function isAdmin() { - return request.auth.uid == 'tUosjZRN6GRv81uRksJ67EIF0853' // James + return request.auth.token.email in [ + 'akrolsmir@gmail.com', + 'jahooma@gmail.com', + 'taowell@gmail.com', + 'manticmarkets@gmail.com' + ] } match /stats/stats { @@ -17,16 +22,17 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - allow update: if resource.data.id == request.auth.uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - // only one referral allowed per user - && !("referredByUserId" in resource.data) - // user can't refer themselves - && !(resource.data.id == request.resource.data.referredByUserId); - // quid pro quos enabled (only once though so nbd) - bc I can't make this work: - // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']); + // User referral rules + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { @@ -68,9 +74,9 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime']) + .hasOnly(['description', 'closeTime', 'question']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { diff --git a/functions/README.md b/functions/README.md index 8013fb20..97a7a33b 100644 --- a/functions/README.md +++ b/functions/README.md @@ -27,6 +27,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started 1. `$ brew install java` 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` + 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database @@ -53,7 +54,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying -0. `$ firebase use prod` to switch to prod +0. After merging, you need to manually deploy to backend: +1. `git checkout main` +1. `git pull origin main` +1. `$ firebase use prod` to switch to prod 1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts index 3822bbf7..b534f0a3 100644 --- a/functions/src/claim-manalink.ts +++ b/functions/src/claim-manalink.ts @@ -28,6 +28,9 @@ export const claimmanalink = newEndpoint({}, async (req, auth) => { if (amount <= 0 || isNaN(amount) || !isFinite(amount)) throw new APIError(500, 'Invalid amount') + if (auth.uid === fromId) + throw new APIError(400, `You can't claim your own manalink`) + const fromDoc = firestore.doc(`users/${fromId}`) const fromSnap = await transaction.get(fromDoc) if (!fromSnap.exists) { diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 1fb6c3af..7cc05760 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -15,11 +15,11 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group } from '../../common/group' +import { Group, GROUP_CHAT_SLUG } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } + [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( @@ -29,12 +29,22 @@ export const createNotification = async ( sourceUser: User, idempotencyKey: string, sourceText: string, - sourceContract?: Contract, - relatedSourceType?: notification_source_types, - relatedUserId?: string, - sourceSlug?: string, - sourceTitle?: string + miscData?: { + contract?: Contract + relatedSourceType?: notification_source_types + relatedUserId?: string + slug?: string + title?: string + } ) => { + const { + contract: sourceContract, + relatedSourceType, + relatedUserId, + slug, + title, + } = miscData ?? {} + const shouldGetNotification = ( userId: string, userToReasonTexts: user_to_reason_texts @@ -70,9 +80,8 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, - sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, - isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, + sourceSlug: slug ? slug : sourceContract?.slug, + sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -254,20 +263,6 @@ export const createNotification = async ( } } - const notifyUserReceivedReferralBonus = async ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - // If the referrer is the market creator, just tell them they joined to bet on their market - reason: - sourceContract?.creatorId === relatedUserId - ? 'user_joined_to_bet_on_your_market' - : 'you_referred_user', - } - } - const notifyContractCreatorOfUniqueBettorsBonus = async ( userToReasonTexts: user_to_reason_texts, userId: string @@ -277,17 +272,6 @@ export const createNotification = async ( } } - const notifyOtherGroupMembersOfComment = async ( - userToReasons: user_to_reason_texts, - userId: string - ) => { - if (shouldGetNotification(userId, userToReasons)) - userToReasons[userId] = { - reason: 'on_group_you_are_member_of', - isSeeOnHref: sourceSlug, - } - } - const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -296,10 +280,6 @@ export const createNotification = async ( } else if (sourceType === 'group' && relatedUserId) { if (sourceUpdateType === 'created') await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) - } else if (sourceType === 'user' && relatedUserId) { - await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) - } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { - await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. @@ -417,3 +397,84 @@ export const createBetFillNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createGroupCommentNotification = async ( + fromUser: User, + toUserId: string, + comment: Comment, + group: Group, + idempotencyKey: string +) => { + if (toUserId === fromUser.id) return + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'on_group_you_are_member_of', + createdTime: Date.now(), + isSeen: false, + sourceId: comment.id, + sourceType: 'comment', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: comment.text, + sourceSlug, + sourceTitle: `${group.name}`, + isSeenOnHref: sourceSlug, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +export const createReferralNotification = async ( + toUser: User, + referredUser: User, + idempotencyKey: string, + bonusAmount: string, + referredByContract?: Contract, + referredByGroup?: Group +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: referredByGroup + ? 'user_joined_from_your_group_invite' + : referredByContract?.creatorId === toUser.id + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + createdTime: Date.now(), + isSeen: false, + sourceId: referredUser.id, + sourceType: 'user', + sourceUpdateType: 'updated', + sourceContractId: referredByContract?.id, + sourceUserName: referredUser.name, + sourceUserUsername: referredUser.username, + sourceUserAvatarUrl: referredUser.avatarUrl, + sourceText: bonusAmount, + // Only pass the contract referral details if they weren't referred to a group + sourceContractCreatorUsername: !referredByGroup + ? referredByContract?.creatorUsername + : undefined, + sourceContractTitle: !referredByGroup + ? referredByContract?.question + : undefined, + sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined, + sourceSlug: referredByGroup + ? groupPath(referredByGroup.slug) + : referredByContract?.slug, + sourceTitle: referredByGroup + ? referredByGroup.name + : referredByContract?.question, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +const groupPath = (groupSlug: string) => `/group/${groupSlug}` diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 332c1872..1f413b6d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,8 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USERNAME, PrivateUser, STARTING_BALANCE, SUS_STARTING_BALANCE, @@ -157,11 +159,11 @@ const addUserToDefaultGroups = async (user: User) => { id: welcomeCommentDoc.id, groupId: group.id, userId: manifoldAccount, - text: `Welcome, ${user.name} (@${user.username})!`, + text: `Welcome, @${user.username} aka ${user.name}!`, createdTime: Date.now(), userName: 'Manifold Markets', - userUsername: 'ManifoldMarkets', - userAvatarUrl: 'https://manifold.markets/logo-bg-white.png', + userUsername: MANIFOLD_USERNAME, + userAvatarUrl: MANIFOLD_AVATAR_URL, }) } } diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html new file mode 100644 index 00000000..5f0c450e --- /dev/null +++ b/functions/src/email-templates/500-mana.html @@ -0,0 +1,29 @@ +<iframe + style="border: 0px; width: 100%; height: 100%" + seamless + sandbox + srcdoc='<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title>7th Day Anniversary Gift!</title><!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; } + body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; } + table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; } + img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; } + p { display:block;margin:13px 0; }</style><!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG/> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--><!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { width:100% !important; } + </style> + <![endif]--><style type="text/css">@media only screen and (min-width:480px) { + .mj-column-per-100 { width:100% !important; max-width: 100%; } + }</style><style media="screen and (min-width:480px)">.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }</style><style type="text/css">[owa] .mj-column-per-100 { width:100% !important; max-width: 100%; }</style><style type="text/css">@media only screen and (max-width:480px) { + table.mj-full-width-mobile { width: 100% !important; } + td.mj-full-width-mobile { width: auto !important; } + }</style></head><body style="word-spacing:normal;background-color:#F4F4F4;"><div style="background-color:#F4F4F4;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tbody><tr><td align="center" style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"><tbody><tr><td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img alt="" height="auto" src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.gif" style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="550"></a></td></tr></tbody></table></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"><div style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"><p class="text-build-content" style="text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" data-testid="4XoHRGw1Y"><span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Running low on Mana? Click the link below to recieve a one time gift of 500 Mana!</span></p></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"><tbody><tr><td style="width:550px;"><a href="https://manifold.markets/link/lj4JbBvE" target="_blank"><img alt="" height="auto" src="https://03jlj.mjt.lu/img/03jlj/b/u71/4pg3.png" style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="550"></a></td></tr></tbody></table></td></tr><tr><td align="left" style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"><p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;" data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did you know, besides making correct predictions, there are plenty of other ways to earn Mana?</span></p><ul><li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;">Recieving tips on comments</span></li><li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;">Unique trader bonus for each user who bets on you markets</span></li><li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;">Reffering friends (click the share button on a market or group!)</span></li><li style="line-height:23px;"><a class="link-build-content" style="color:inherit;; text-decoration: none;" target="_blank" href="https://manifold.markets/group/bugs?s=most-traded"><span style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;"> and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;" target="_blank" href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving feedback</u></span></a></li></ul><p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p><p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p><p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p><p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0; margin-bottom: 10px;"> </p></div></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tbody><tr><td align="center" style="font-size:0px;padding:0px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> + </div><!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"><tbody><tr><td style="vertical-align:top;padding:0;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"><tbody><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"><div style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"><p style="margin: 10px 0;">This e-mail has been sent to [[EMAIL_TO]], <a href="{{unsubscribeLink}}” style="color:inherit;text-decoration:none;" target="_blank">click here to unsubscribe</a>.</p></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"><div style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"></div></td></tr></tbody></table></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>' +></iframe> diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 60534679..a29f982c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -302,7 +302,7 @@ export const sendNewCommentEmail = async ( )}` } - const subject = `Comment from ${commentorName} on ${question}` + const subject = `Comment on ${question}` const from = `${commentorName} on Manifold <no-reply@manifold.markets>` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { diff --git a/functions/src/index.ts b/functions/src/index.ts index 3055f8dc..df311886 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -22,6 +22,7 @@ export * from './on-update-user' export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' +export * from './score-contracts' // v2 export * from './health' diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index ee9952bf..f31674a1 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -64,7 +64,7 @@ async function sendMarketCloseEmails() { user, 'closed' + contract.id.slice(6, contract.id.length), contract.closeTime?.toString() ?? new Date().toString(), - contract + { contract } ) } } diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 78fd1399..af4690b0 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore answerCreator, eventId, answer.text, - contract + { contract } ) }) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index fc2e0053..d33e71dd 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!previousUniqueBettorIds) { const contractBets = ( - await firestore - .collection(`contracts/${contractId}/bets`) - .where('userId', '!=', contract.creatorId) - .get() + await firestore.collection(`contracts/${contractId}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { @@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = - !previousUniqueBettorIds.includes(bettorId) && - bettorId !== contract.creatorId + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) // Update contract unique bettors @@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( uniqueBettorCount: newUniqueBettorIds.length, }) } - if (!isNewUniqueBettor) return + + // No need to give a bonus for the creator's bet + if (!isNewUniqueBettor || bettorId == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( fromUser, eventId + '-bonus', result.txn.amount + '', - contract, - undefined, - // No need to set the user id, we'll use the contract creator id - undefined, - contract.slug, - contract.question + { + contract, + slug: contract.slug, + title: contract.question, + } ) } } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index f7839b44..8d841ac0 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUser = comment.replyToCommentId + const relatedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId @@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - contract, - relatedSourceType, - relatedUser + { contract, relatedSourceType, relatedUserId } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 7217e602..0064480f 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -3,7 +3,7 @@ import { Comment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' -import { createNotification } from './create-notification' +import { createGroupCommentNotification } from './create-notification' const firestore = admin.firestore() export const onCreateCommentOnGroup = functions.firestore @@ -29,23 +29,17 @@ export const onCreateCommentOnGroup = functions.firestore const group = groupSnapshot.data() as Group await firestore.collection('groups').doc(groupId).update({ - mostRecentActivityTime: comment.createdTime, + mostRecentChatActivityTime: comment.createdTime, }) await Promise.all( group.memberIds.map(async (memberId) => { - return await createNotification( - comment.id, - 'comment', - 'created', + return await createGroupCommentNotification( creatorSnapshot.data() as User, - eventId, - comment.text, - undefined, - undefined, memberId, - `/group/${group.slug}`, - `${group.name}` + comment, + group, + eventId ) }) ) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 28682793..a43beda7 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore contractCreator, eventId, richTextToString(contract.description as JSONContent), - contract + { contract } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 1d041c04..47618d7a 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore groupCreator, eventId, group.about, - undefined, - undefined, - memberId, - group.slug, - group.name + { + relatedUserId: memberId, + slug: group.slug, + title: group.name, + } ) } }) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index d55b2be4..ba17f3e7 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore liquidityProvider, eventId, liquidity.amount.toString(), - contract + { contract } ) }) diff --git a/functions/src/on-delete-group.ts b/functions/src/on-delete-group.ts index ca833254..e5531d7b 100644 --- a/functions/src/on-delete-group.ts +++ b/functions/src/on-delete-group.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { Group } from 'common/group' import { Contract } from 'common/contract' + const firestore = admin.firestore() export const onDeleteGroup = functions.firestore @@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore .collection('contracts') .where('groupSlugs', 'array-contains', group.slug) .get() + console.log("contracts with group's slug:", contracts) for (const doc of contracts.docs) { const contract = doc.data() as Contract + const newGroupLinks = contract.groupLinks?.filter( + (link) => link.slug !== group.slug + ) + // remove the group from the contract await firestore .collection('contracts') .doc(contract.id) .update({ - groupSlugs: (contract.groupSlugs ?? []).filter( - (groupSlug) => groupSlug !== group.slug - ), + groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug), + groupLinks: newGroupLinks ?? [], }) } }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index ad85f4d3..9a6e6dce 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - undefined, - undefined, - follow.userId + { relatedUserId: follow.userId } ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 4674bd82..2042f726 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, resolutionText, - contract + { contract } ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, sourceText, - contract + { contract } ) } }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index feaa6443..3ab2a249 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,7 +12,15 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - // TODO: create notification with isSeeOnHref set to the group's /group/questions url + + if (prevGroup.contractIds.length < group.contractIds.length) { + await firestore + .collection('groups') + .doc(group.id) + .update({ mostRecentContractAddedTime: Date.now() }) + //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url + // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two + } await firestore .collection('groups') diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index 0ace3c53..f5558730 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -2,11 +2,12 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { REFERRAL_AMOUNT, User } from '../../common/user' import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' -import { createNotification } from './create-notification' +import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' import { LimitBet } from 'common/bet' import { QuerySnapshot } from 'firebase-admin/firestore' +import { Group } from 'common/group' const firestore = admin.firestore() export const onUpdateUser = functions.firestore @@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { } console.log(`referredByContract: ${referredByContract}`) + let referredByGroup: Group | undefined = undefined + if (user.referredByGroupId) { + const referredByGroupDoc = firestore.doc( + `groups/${user.referredByGroupId}` + ) + referredByGroup = await transaction + .get(referredByGroupDoc) + .then((snap) => snap.data() as Group) + } + console.log(`referredByGroup: ${referredByGroup}`) + const txns = ( await firestore .collection('txns') @@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, }) - await createNotification( - user.id, - 'user', - 'updated', + await createReferralNotification( + referredByUser, user, eventId, txn.amount.toString(), referredByContract, - 'user', - referredByUser.id, - referredByContract?.slug, - referredByContract?.question + referredByGroup ) }) } diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index b8c0ca0e..97ff9780 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -6,7 +6,7 @@ import { Query, Transaction, } from 'firebase-admin/firestore' -import { groupBy, mapValues, sumBy } from 'lodash' +import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -153,10 +153,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { log('Main transaction finished.') if (result.newBet.amount !== 0) { - const userIds = [ + const userIds = uniq([ auth.uid, ...(result.makers ?? []).map((maker) => maker.bet.userId), - ] + ]) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) log('Share redemption transaction finished.') } diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts new file mode 100644 index 00000000..57976ff2 --- /dev/null +++ b/functions/src/score-contracts.ts @@ -0,0 +1,54 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Bet } from 'common/bet' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { log } from './utils' + +export const scoreContracts = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + await scoreContractsInternal() + }) +const firestore = admin.firestore() + +async function scoreContractsInternal() { + const now = Date.now() + const lastHour = now - 60 * 60 * 1000 + const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const activeContractsSnap = await firestore + .collection('contracts') + .where('lastUpdatedTime', '>', lastHour) + .get() + const activeContracts = activeContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + // We have to downgrade previously active contracts to allow the new ones to bubble up + const previouslyActiveContractsSnap = await firestore + .collection('contracts') + .where('popularityScore', '>', 0) + .get() + const activeContractIds = activeContracts.map((c) => c.id) + const previouslyActiveContracts = previouslyActiveContractsSnap.docs + .map((doc) => doc.data() as Contract) + .filter((c) => !activeContractIds.includes(c.id)) + + const contracts = activeContracts.concat(previouslyActiveContracts) + log(`Found ${contracts.length} contracts to score`) + + for (const contract of contracts) { + const bets = await firestore + .collection(`contracts/${contract.id}/bets`) + .where('createdTime', '>', last3Days) + .get() + const bettors = bets.docs + .map((doc) => doc.data() as Bet) + .map((bet) => bet.userId) + const score = uniq(bettors).length + if (contract.popularityScore !== score) + await firestore + .collection('contracts') + .doc(contract.id) + .update({ popularityScore: score }) + } +} diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts index 8fe90807..3436bcbc 100644 --- a/functions/src/scripts/convert-categories.ts +++ b/functions/src/scripts/convert-categories.ts @@ -1,14 +1,9 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -initAdmin() - import { getValues, isProd } from '../utils' -import { - CATEGORIES_GROUP_SLUG_POSTFIX, - DEFAULT_CATEGORIES, -} from 'common/categories' -import { Group } from 'common/group' +import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' +import { Group, GroupLink } from 'common/group' import { uniq } from 'lodash' import { Contract } from 'common/contract' import { User } from 'common/user' @@ -18,28 +13,12 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' +initAdmin() + const adminFirestore = admin.firestore() -async function convertCategoriesToGroups() { - const groups = await getValues<Group>(adminFirestore.collection('groups')) - const contracts = await getValues<Contract>( - adminFirestore.collection('contracts') - ) - for (const group of groups) { - const groupContracts = contracts.filter((contract) => - group.contractIds.includes(contract.id) - ) - for (const contract of groupContracts) { - await adminFirestore - .collection('contracts') - .doc(contract.id) - .update({ - groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), - }) - } - } - - for (const category of Object.values(DEFAULT_CATEGORIES)) { +const convertCategoriesToGroupsInternal = async (categories: string[]) => { + for (const category of categories) { const markets = await getValues<Contract>( adminFirestore .collection('contracts') @@ -77,7 +56,7 @@ async function convertCategoriesToGroups() { createdTime: Date.now(), anyoneCanJoin: true, memberIds: [manifoldAccount], - about: 'Official group for all things related to ' + category, + about: 'Default group for all things related to ' + category, mostRecentActivityTime: Date.now(), contractIds: markets.map((market) => market.id), chatDisabled: true, @@ -93,16 +72,35 @@ async function convertCategoriesToGroups() { }) for (const market of markets) { + if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) + continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: newGroup.id, + createdTime: Date.now(), + slug: newGroup.slug, + name: newGroup.name, + } as GroupLink, + ] await adminFirestore .collection('contracts') .doc(market.id) .update({ - groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]), + groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), + groupLinks: newGroupLinks, }) } } } +async function convertCategoriesToGroups() { + // const defaultCategories = Object.values(DEFAULT_CATEGORIES) + const moreCategories = ['world', 'culture'] + await convertCategoriesToGroupsInternal(moreCategories) +} + if (require.main === module) { convertCategoriesToGroups() .then(() => process.exit()) diff --git a/functions/src/scripts/link-contracts-to-groups.ts b/functions/src/scripts/link-contracts-to-groups.ts new file mode 100644 index 00000000..e3296160 --- /dev/null +++ b/functions/src/scripts/link-contracts-to-groups.ts @@ -0,0 +1,53 @@ +import { getValues } from 'functions/src/utils' +import { Group } from 'common/group' +import { Contract } from 'common/contract' +import { initAdmin } from 'functions/src/scripts/script-init' +import * as admin from 'firebase-admin' +import { filterDefined } from 'common/util/array' +import { uniq } from 'lodash' + +initAdmin() + +const adminFirestore = admin.firestore() + +const addGroupIdToContracts = async () => { + const groups = await getValues<Group>(adminFirestore.collection('groups')) + + for (const group of groups) { + const groupContracts = await getValues<Contract>( + adminFirestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + ) + + for (const contract of groupContracts) { + const oldGroupLinks = contract.groupLinks?.filter( + (l) => l.slug != group.slug + ) + const newGroupLinks = filterDefined([ + ...(oldGroupLinks ?? []), + group.id + ? { + slug: group.slug, + name: group.name, + groupId: group.id, + createdTime: Date.now(), + } + : undefined, + ]) + await adminFirestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + } +} + +if (require.main === module) { + addGroupIdToContracts() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 3407760b..40ea0f4a 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { sumBy } from 'lodash' +import { sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -7,11 +7,12 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { getValues } from './utils' +import { getValues, log } from './utils' import { Bet } from '../../common/bet' import { floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), @@ -23,7 +24,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { + const result = await firestore.runTransaction(async (transaction) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) @@ -97,8 +98,14 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) - return { status: 'success' } + return { newBet, makers } }) + + const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) + log('Share redemption transaction finished.') + + return { status: 'success' } }) const firestore = admin.firestore() diff --git a/web/.prettierignore b/web/.prettierignore index b79c5513..6cc1e5c7 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ # Ignore Next artifacts .next/ -out/ \ No newline at end of file +out/ +public/**/*.json \ No newline at end of file diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx new file mode 100644 index 00000000..7a839a7a --- /dev/null +++ b/web/components/NotificationSettings.tsx @@ -0,0 +1,210 @@ +import { useUser } from 'web/hooks/use-user' +import React, { useEffect, useState } from 'react' +import { notification_subscribe_types, PrivateUser } from 'common/user' +import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' +import toast from 'react-hot-toast' +import { track } from '@amplitude/analytics-browser' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' + +export function NotificationSettings() { + const user = useUser() + const [notificationSettings, setNotificationSettings] = + useState<notification_subscribe_types>('all') + const [emailNotificationSettings, setEmailNotificationSettings] = + useState<notification_subscribe_types>('all') + const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) + + useEffect(() => { + if (user) listenForPrivateUser(user.id, setPrivateUser) + }, [user]) + + useEffect(() => { + if (!privateUser) return + if (privateUser.notificationPreferences) { + setNotificationSettings(privateUser.notificationPreferences) + } + if ( + privateUser.unsubscribedFromResolutionEmails && + privateUser.unsubscribedFromCommentEmails && + privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('none') + } else if ( + !privateUser.unsubscribedFromResolutionEmails && + !privateUser.unsubscribedFromCommentEmails && + !privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('all') + } else { + setEmailNotificationSettings('less') + } + }, [privateUser]) + + const loading = 'Changing Notifications Settings' + const success = 'Notification Settings Changed!' + function changeEmailNotifications(newValue: notification_subscribe_types) { + if (!privateUser) return + if (newValue === 'all') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: false, + unsubscribedFromAnswerEmails: false, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'less') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'none') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: true, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + } + + function changeInAppNotificationSettings( + newValue: notification_subscribe_types + ) { + if (!privateUser) return + track('In-App Notification Preferences Changed', { + newPreference: newValue, + oldPreference: privateUser.notificationPreferences, + }) + toast.promise( + updatePrivateUser(privateUser.id, { + notificationPreferences: newValue, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + + useEffect(() => { + if (privateUser && privateUser.notificationPreferences) + setNotificationSettings(privateUser.notificationPreferences) + else setNotificationSettings('all') + }, [privateUser]) + + if (!privateUser) { + return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> + } + + function NotificationSettingLine(props: { + label: string + highlight: boolean + }) { + const { label, highlight } = props + return ( + <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> + {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} + {label} + </Row> + ) + } + + return ( + <div className={'p-2'}> + <div>In App Notifications</div> + <ChoicesToggleGroup + currentChoice={notificationSettings} + choicesMap={{ All: 'all', Less: 'less', None: 'none' }} + setChoice={(choice) => + changeInAppNotificationSettings( + choice as notification_subscribe_types + ) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> + <div className={'mt-4 text-sm'}> + <div> + <div className={''}> + You will receive notifications for: + <NotificationSettingLine + label={"Resolution of questions you've interacted with"} + highlight={notificationSettings !== 'none'} + /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={'Activity on your own questions, comments, & answers'} + /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Activity on questions you're betting on"} + /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Income & referral bonuses you've received"} + /> + <NotificationSettingLine + label={"Activity on questions you've ever bet or commented on"} + highlight={notificationSettings === 'all'} + /> + </div> + </div> + </div> + <div className={'mt-4'}>Email Notifications</div> + <ChoicesToggleGroup + currentChoice={emailNotificationSettings} + choicesMap={{ All: 'all', Less: 'less', None: 'none' }} + setChoice={(choice) => + changeEmailNotifications(choice as notification_subscribe_types) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> + <div className={'mt-4 text-sm'}> + <div> + You will receive emails for: + <NotificationSettingLine + label={"Resolution of questions you're betting on"} + highlight={emailNotificationSettings !== 'none'} + /> + <NotificationSettingLine + label={'Closure of your questions'} + highlight={emailNotificationSettings !== 'none'} + /> + <NotificationSettingLine + label={'Activity on your questions'} + highlight={emailNotificationSettings === 'all'} + /> + <NotificationSettingLine + label={"Activity on questions you've answered or commented on"} + highlight={emailNotificationSettings === 'all'} + /> + </div> + </div> + </div> + ) +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index a31957cb..426a9371 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -41,7 +41,7 @@ export function AmountInput(props: { <span className="bg-gray-200 text-sm">{label}</span> <input className={clsx( - 'input input-bordered max-w-[200px] text-lg', + 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', error && 'input-error', inputClassName )} diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx new file mode 100644 index 00000000..653368b6 --- /dev/null +++ b/web/components/auth-context.tsx @@ -0,0 +1,77 @@ +import { createContext, useEffect } from 'react' +import { User } from 'common/user' +import { onIdTokenChanged } from 'firebase/auth' +import { + auth, + listenForUser, + getUser, + setCachedReferralInfoForUser, +} from 'web/lib/firebase/users' +import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth' +import { createUser } from 'web/lib/firebase/api' +import { randomString } from 'common/util/random' +import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' + +// Either we haven't looked up the logged in user yet (undefined), or we know +// the user is not logged in (null), or we know the user is logged in (User). +type AuthUser = undefined | null | User + +const CACHED_USER_KEY = 'CACHED_USER_KEY' + +const ensureDeviceToken = () => { + let deviceToken = localStorage.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + localStorage.setItem('device-token', deviceToken) + } + return deviceToken +} + +export const AuthContext = createContext<AuthUser>(null) + +export function AuthProvider({ children }: any) { + const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined) + + useEffect(() => { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + }, [setAuthUser]) + + useEffect(() => { + return onIdTokenChanged(auth, async (fbUser) => { + if (fbUser) { + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + let user = await getUser(fbUser.uid) + if (!user) { + const deviceToken = ensureDeviceToken() + user = (await createUser({ deviceToken })) as User + } + setAuthUser(user) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) + } else { + // User logged out; reset to null + deleteAuthCookies() + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) + } + }) + }, [setAuthUser]) + + const authUserId = authUser?.id + const authUsername = authUser?.username + useEffect(() => { + if (authUserId && authUsername) { + identifyUser(authUserId) + setUserProperty('username', authUsername) + return listenForUser(authUserId, setAuthUser) + } + }, [authUserId, authUsername, setAuthUser]) + + return ( + <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> + ) +} diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 53257deb..0436d61c 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -31,6 +31,7 @@ export function Avatar(props: { !noLink && 'cursor-pointer', className )} + style={{ maxWidth: `${s * 0.25}rem` }} src={avatarUrl} onClick={onClick} alt={username} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 8343d696..c638fcde 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import { partition, sum, sumBy } from 'lodash' +import { clamp, partition, sum, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' @@ -13,33 +13,35 @@ import { formatPercent, formatWithCommas, } from 'common/util/format' -import { getBinaryCpmmBetInfo } from 'common/new-bet' +import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api' import { sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { + BinaryOutcomeLabel, + HigherLabel, + LowerLabel, + NoLabel, + YesLabel, +} from './outcome-label' import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' -import { - getFormattedMappedValue, - getPseudoProbability, -} from 'common/pseudo-numeric' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' -import { ProbabilityInput } from './probability-input' +import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' -import { removeUndefinedProps } from 'common/util/object' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' -import { BucketInput } from './bucket-input' import { PillButton } from './buttons/pill-button' +import { YesNoSelector } from './yes-no-selector' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -49,14 +51,10 @@ export function BetPanel(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 - return ( <Col className={className}> <SellRow @@ -76,15 +74,20 @@ export function BetPanel(props: { setIsLimitOrder={setIsLimitOrder} /> <BuyPanel + hidden={isLimitOrder} + contract={contract} + user={user} + unfilledBets={unfilledBets} + /> + <LimitOrderPanel + hidden={!isLimitOrder} contract={contract} user={user} - isLimitOrder={isLimitOrder} unfilledBets={unfilledBets} /> - <SignUpPrompt /> </Col> - {showLimitOrders && ( + {unfilledBets.length > 0 && ( <LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> )} </Col> @@ -104,9 +107,6 @@ export function SimpleBetPanel(props: { const [isLimitOrder, setIsLimitOrder] = useState(false) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 return ( <Col className={className}> @@ -126,18 +126,24 @@ export function SimpleBetPanel(props: { setIsLimitOrder={setIsLimitOrder} /> <BuyPanel + hidden={isLimitOrder} contract={contract} user={user} unfilledBets={unfilledBets} selected={selected} onBuySuccess={onBetSuccess} - isLimitOrder={isLimitOrder} /> - + <LimitOrderPanel + hidden={!isLimitOrder} + contract={contract} + user={user} + unfilledBets={unfilledBets} + onBuySuccess={onBetSuccess} + /> <SignUpPrompt /> </Col> - {showLimitOrders && ( + {unfilledBets.length > 0 && ( <LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> )} </Col> @@ -148,21 +154,17 @@ function BuyPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract user: User | null | undefined unfilledBets: Bet[] - isLimitOrder?: boolean + hidden: boolean selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { - const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } = - props + const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' - const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) + const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) - const [limitProb, setLimitProb] = useState<number | undefined>( - Math.round(100 * initialProb) - ) const [error, setError] = useState<string | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) @@ -177,7 +179,7 @@ function BuyPanel(props: { }, [selected, focusAmountInput]) function onBetChoice(choice: 'YES' | 'NO') { - setBetChoice(choice) + setOutcome(choice) setWasSubmitted(false) focusAmountInput() } @@ -185,29 +187,22 @@ function BuyPanel(props: { function onBetChange(newAmount: number | undefined) { setWasSubmitted(false) setBetAmount(newAmount) - if (!betChoice) { - setBetChoice('YES') + if (!outcome) { + setOutcome('YES') } } async function submitBet() { if (!user || !betAmount) return - if (isLimitOrder && limitProb === undefined) return - - const limitProbScaled = - isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined setError(undefined) setIsSubmitting(true) - placeBet( - removeUndefinedProps({ - amount: betAmount, - outcome: betChoice, - contractId: contract.id, - limitProb: limitProbScaled, - }) - ) + placeBet({ + outcome, + amount: betAmount, + contractId: contract.id, + }) .then((r) => { console.log('placed bet. Result:', r) setIsSubmitting(false) @@ -231,21 +226,18 @@ function BuyPanel(props: { slug: contract.slug, contractId: contract.id, amount: betAmount, - outcome: betChoice, - isLimitOrder, - limitProb: limitProbScaled, + outcome, + isLimitOrder: false, }) } const betDisabled = isSubmitting || !betAmount || error - const limitProbFrac = (limitProb ?? 0) / 100 - const { newPool, newP, newBet } = getBinaryCpmmBetInfo( - betChoice ?? 'YES', + outcome ?? 'YES', betAmount ?? 0, contract, - isLimitOrder ? limitProbFrac : undefined, + undefined, unfilledBets as LimitBet[] ) @@ -253,11 +245,7 @@ function BuyPanel(props: { const probStayedSame = formatPercent(resultProb) === formatPercent(initialProb) - const remainingMatched = isLimitOrder - ? ((newBet.orderAmount ?? 0) - newBet.amount) / - (betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac) - : 0 - const currentPayout = newBet.shares + remainingMatched + const currentPayout = newBet.shares const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) @@ -267,26 +255,17 @@ function BuyPanel(props: { const format = getFormattedMappedValue(contract) return ( - <> - <div className="my-3 text-left text-sm text-gray-500">Direction</div> - <Row className="mb-4 items-center gap-2"> - <PillButton - selected={betChoice === 'YES'} - onSelect={() => onBetChoice('YES')} - big - color="bg-primary" - > - {isPseudoNumeric ? 'Higher' : 'Yes'} - </PillButton> - <PillButton - selected={betChoice === 'NO'} - onSelect={() => onBetChoice('NO')} - big - color="bg-red-400" - > - {isPseudoNumeric ? 'Lower' : 'No'} - </PillButton> - </Row> + <Col className={hidden ? 'hidden' : ''}> + <div className="my-3 text-left text-sm text-gray-500"> + {isPseudoNumeric ? 'Direction' : 'Outcome'} + </div> + <YesNoSelector + className="mb-4" + btnClassName="flex-1" + selected={outcome} + onSelect={(choice) => onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} + /> <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput @@ -298,61 +277,21 @@ function BuyPanel(props: { disabled={isSubmitting} inputRef={inputRef} /> - {isLimitOrder && ( - <> - <Row className="my-3 items-center gap-2 text-left text-sm text-gray-500"> - Limit {isPseudoNumeric ? 'value' : 'probability'} - <InfoTooltip - text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${ - isPseudoNumeric ? 'value' : 'probability' - } and wait to match other bets.`} - /> - </Row> - {isPseudoNumeric ? ( - <BucketInput - contract={contract} - onBucketChange={(value) => - setLimitProb( - value === undefined - ? undefined - : 100 * - getPseudoProbability( - value, - contract.min, - contract.max, - contract.isLogScale - ) - ) - } - isSubmitting={isSubmitting} - /> - ) : ( - <ProbabilityInput - inputClassName="w-full max-w-none" - prob={limitProb} - onChange={setLimitProb} - disabled={isSubmitting} - /> - )} - </> - )} <Col className="mt-3 w-full gap-3"> - {!isLimitOrder && ( - <Row className="items-center justify-between text-sm"> - <div className="text-gray-500"> - {isPseudoNumeric ? 'Estimated value' : 'Probability'} + <Row className="items-center justify-between text-sm"> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> + {probStayedSame ? ( + <div>{format(initialProb)}</div> + ) : ( + <div> + {format(initialProb)} + <span className="mx-2">→</span> + {format(resultProb)} </div> - {probStayedSame ? ( - <div>{format(initialProb)}</div> - ) : ( - <div> - {format(initialProb)} - <span className="mx-2">→</span> - {format(resultProb)} - </div> - )} - </Row> - )} + )} + </Row> <Row className="items-center justify-between gap-2 text-sm"> <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> @@ -361,7 +300,7 @@ function BuyPanel(props: { 'Max payout' ) : ( <> - Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> + Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} /> </> )} </div> @@ -386,7 +325,7 @@ function BuyPanel(props: { 'btn flex-1', betDisabled ? 'btn-disabled' - : betChoice === 'YES' + : outcome === 'YES' ? 'btn-primary' : 'border-none bg-red-400 hover:bg-red-500', isSubmitting ? 'loading' : '' @@ -397,10 +336,352 @@ function BuyPanel(props: { </button> )} - {wasSubmitted && ( - <div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div> + {wasSubmitted && <div className="mt-4">Bet submitted!</div>} + </Col> + ) +} + +function LimitOrderPanel(props: { + contract: CPMMBinaryContract | PseudoNumericContract + user: User | null | undefined + unfilledBets: Bet[] + hidden: boolean + onBuySuccess?: () => void +}) { + const { contract, user, unfilledBets, hidden, onBuySuccess } = props + + const initialProb = getProbability(contract) + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + + const [betAmount, setBetAmount] = useState<number | undefined>(undefined) + const [lowLimitProb, setLowLimitProb] = useState<number | undefined>() + const [highLimitProb, setHighLimitProb] = useState<number | undefined>() + const betChoice = 'YES' + const [error, setError] = useState<string | undefined>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [wasSubmitted, setWasSubmitted] = useState(false) + + const rangeError = + lowLimitProb !== undefined && + highLimitProb !== undefined && + lowLimitProb >= highLimitProb + + const outOfRangeError = + (lowLimitProb !== undefined && + (lowLimitProb <= 0 || lowLimitProb >= 100)) || + (highLimitProb !== undefined && + (highLimitProb <= 0 || highLimitProb >= 100)) + + const hasYesLimitBet = lowLimitProb !== undefined && !!betAmount + const hasNoLimitBet = highLimitProb !== undefined && !!betAmount + const hasTwoBets = hasYesLimitBet && hasNoLimitBet + + const betDisabled = + isSubmitting || + !betAmount || + rangeError || + outOfRangeError || + error || + (!hasYesLimitBet && !hasNoLimitBet) + + const yesLimitProb = + lowLimitProb === undefined + ? undefined + : clamp(lowLimitProb / 100, 0.001, 0.999) + const noLimitProb = + highLimitProb === undefined + ? undefined + : clamp(highLimitProb / 100, 0.001, 0.999) + + const amount = betAmount ?? 0 + const shares = + yesLimitProb !== undefined && noLimitProb !== undefined + ? Math.min(amount / yesLimitProb, amount / (1 - noLimitProb)) + : yesLimitProb !== undefined + ? amount / yesLimitProb + : noLimitProb !== undefined + ? amount / (1 - noLimitProb) + : 0 + + const yesAmount = shares * (yesLimitProb ?? 1) + const noAmount = shares * (1 - (noLimitProb ?? 0)) + + const profitIfBothFilled = shares - (yesAmount + noAmount) + + function onBetChange(newAmount: number | undefined) { + setWasSubmitted(false) + setBetAmount(newAmount) + } + + async function submitBet() { + if (!user || betDisabled) return + + setError(undefined) + setIsSubmitting(true) + + const betsPromise = hasTwoBets + ? Promise.all([ + placeBet({ + outcome: 'YES', + amount: yesAmount, + limitProb: yesLimitProb, + contractId: contract.id, + }), + placeBet({ + outcome: 'NO', + amount: noAmount, + limitProb: noLimitProb, + contractId: contract.id, + }), + ]) + : placeBet({ + outcome: hasYesLimitBet ? 'YES' : 'NO', + amount: betAmount, + contractId: contract.id, + limitProb: hasYesLimitBet ? yesLimitProb : noLimitProb, + }) + + betsPromise + .catch((e) => { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error placing bet') + } + setIsSubmitting(false) + }) + .then((r) => { + console.log('placed bet. Result:', r) + setIsSubmitting(false) + setWasSubmitted(true) + setBetAmount(undefined) + if (onBuySuccess) onBuySuccess() + }) + + if (hasYesLimitBet) { + track('bet', { + location: 'bet panel', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount: yesAmount, + outcome: 'YES', + limitProb: yesLimitProb, + isLimitOrder: true, + isRangeOrder: hasTwoBets, + }) + } + if (hasNoLimitBet) { + track('bet', { + location: 'bet panel', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount: noAmount, + outcome: 'NO', + limitProb: noLimitProb, + isLimitOrder: true, + isRangeOrder: hasTwoBets, + }) + } + } + + const { + currentPayout: yesPayout, + currentReturn: yesReturn, + totalFees: yesFees, + newBet: yesBet, + } = getBinaryBetStats( + 'YES', + yesAmount, + contract, + yesLimitProb ?? initialProb, + unfilledBets as LimitBet[] + ) + const yesReturnPercent = formatPercent(yesReturn) + + const { + currentPayout: noPayout, + currentReturn: noReturn, + totalFees: noFees, + newBet: noBet, + } = getBinaryBetStats( + 'NO', + noAmount, + contract, + noLimitProb ?? initialProb, + unfilledBets as LimitBet[] + ) + const noReturnPercent = formatPercent(noReturn) + + return ( + <Col className={hidden ? 'hidden' : ''}> + <Row className="mt-1 items-center gap-4"> + <Col className="gap-2"> + <div className="relative ml-1 text-sm text-gray-500"> + Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at + </div> + <ProbabilityOrNumericInput + contract={contract} + prob={lowLimitProb} + setProb={setLowLimitProb} + isSubmitting={isSubmitting} + /> + </Col> + <Col className="gap-2"> + <div className="ml-1 text-sm text-gray-500"> + Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at + </div> + <ProbabilityOrNumericInput + contract={contract} + prob={highLimitProb} + setProb={setHighLimitProb} + isSubmitting={isSubmitting} + /> + </Col> + </Row> + + {outOfRangeError && ( + <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> + Limit is out of range + </div> )} - </> + {rangeError && !outOfRangeError && ( + <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> + {isPseudoNumeric ? 'HIGHER' : 'YES'} limit must be less than{' '} + {isPseudoNumeric ? 'LOWER' : 'NO'} limit + </div> + )} + + <div className="mt-1 mb-3 text-left text-sm text-gray-500"> + Max amount<span className="ml-1 text-red-500">*</span> + </div> + <BuyAmountInput + inputClassName="w-full max-w-none" + amount={betAmount} + onChange={onBetChange} + error={error} + setError={setError} + disabled={isSubmitting} + /> + + <Col className="mt-3 w-full gap-3"> + {(hasTwoBets || (hasYesLimitBet && yesBet.amount !== 0)) && ( + <Row className="items-center justify-between gap-2 text-sm"> + <div className="whitespace-nowrap text-gray-500"> + {isPseudoNumeric ? ( + <HigherLabel /> + ) : ( + <BinaryOutcomeLabel outcome={'YES'} /> + )}{' '} + filled now + </div> + <div className="mr-2 whitespace-nowrap"> + {formatMoney(yesBet.amount)} of{' '} + {formatMoney(yesBet.orderAmount ?? 0)} + </div> + </Row> + )} + {(hasTwoBets || (hasNoLimitBet && noBet.amount !== 0)) && ( + <Row className="items-center justify-between gap-2 text-sm"> + <div className="whitespace-nowrap text-gray-500"> + {isPseudoNumeric ? ( + <LowerLabel /> + ) : ( + <BinaryOutcomeLabel outcome={'NO'} /> + )}{' '} + filled now + </div> + <div className="mr-2 whitespace-nowrap"> + {formatMoney(noBet.amount)} of{' '} + {formatMoney(noBet.orderAmount ?? 0)} + </div> + </Row> + )} + {hasTwoBets && ( + <Row className="items-center justify-between gap-2 text-sm"> + <div className="whitespace-nowrap text-gray-500"> + Profit if both orders filled + </div> + <div className="mr-2 whitespace-nowrap"> + {formatMoney(profitIfBothFilled)} + </div> + </Row> + )} + {hasYesLimitBet && !hasTwoBets && ( + <Row className="items-center justify-between gap-2 text-sm"> + <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> + <div> + {isPseudoNumeric ? ( + 'Max payout' + ) : ( + <> + Max <BinaryOutcomeLabel outcome={'YES'} /> payout + </> + )} + </div> + <InfoTooltip + text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`} + /> + </Row> + <div> + <span className="mr-2 whitespace-nowrap"> + {formatMoney(yesPayout)} + </span> + (+{yesReturnPercent}) + </div> + </Row> + )} + {hasNoLimitBet && !hasTwoBets && ( + <Row className="items-center justify-between gap-2 text-sm"> + <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> + <div> + {isPseudoNumeric ? ( + 'Max payout' + ) : ( + <> + Max <BinaryOutcomeLabel outcome={'NO'} /> payout + </> + )} + </div> + <InfoTooltip + text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`} + /> + </Row> + <div> + <span className="mr-2 whitespace-nowrap"> + {formatMoney(noPayout)} + </span> + (+{noReturnPercent}) + </div> + </Row> + )} + </Col> + + {(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />} + + {user && ( + <button + className={clsx( + 'btn flex-1', + betDisabled + ? 'btn-disabled' + : betChoice === 'YES' + ? 'btn-primary' + : 'border-none bg-red-400 hover:bg-red-500', + isSubmitting ? 'loading' : '' + )} + onClick={betDisabled ? undefined : submitBet} + > + {isSubmitting + ? 'Submitting...' + : `Submit order${hasTwoBets ? 's' : ''}`} + </button> + )} + + {wasSubmitted && <div className="mt-4">Order submitted!</div>} + </Col> ) } @@ -413,7 +694,7 @@ function QuickOrLimitBet(props: { return ( <Row className="align-center mb-4 justify-between"> <div className="text-4xl">Bet</div> - <Row className="mt-2 items-center gap-2"> + <Row className="mt-1 items-center gap-2"> <PillButton selected={!isLimitOrder} onSelect={() => { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index db6b0d05..a306a020 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' -const CONTRACTS_PER_PAGE = 20 +const CONTRACTS_PER_PAGE = 50 export function BetsList(props: { user: User @@ -78,10 +78,10 @@ export function BetsList(props: { const getTime = useTimeSinceFirstRender() useEffect(() => { - if (bets && contractsById) { - trackLatency('portfolio', getTime()) + if (bets && contractsById && signedInUser) { + trackLatency(signedInUser.id, 'portfolio', getTime()) } - }, [bets, contractsById, getTime]) + }, [signedInUser, bets, contractsById, getTime]) if (!bets || !contractsById) { return <LoadingIndicator /> diff --git a/web/components/bucket-input.tsx b/web/components/bucket-input.tsx index 195032dc..19dacd65 100644 --- a/web/components/bucket-input.tsx +++ b/web/components/bucket-input.tsx @@ -9,8 +9,9 @@ export function BucketInput(props: { contract: NumericContract | PseudoNumericContract isSubmitting?: boolean onBucketChange: (value?: number, bucket?: string) => void + placeholder?: string }) { - const { contract, isSubmitting, onBucketChange } = props + const { contract, isSubmitting, onBucketChange, placeholder } = props const [numberString, setNumberString] = useState('') @@ -39,7 +40,7 @@ export function BucketInput(props: { error={undefined} disabled={isSubmitting} numberString={numberString} - label="Value" + placeholder={placeholder} /> ) } diff --git a/web/components/button.tsx b/web/components/button.tsx index 3b59581b..8877c023 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,34 +1,49 @@ import { ReactNode } from 'react' import clsx from 'clsx' -export default function Button(props: { +export function Button(props: { className?: string onClick?: () => void - color: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' children?: ReactNode + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' + color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white' type?: 'button' | 'reset' | 'submit' + disabled?: boolean }) { const { + children, className, onClick, - children, + size = 'md', color = 'indigo', type = 'button', + disabled = false, } = props + const sizeClasses = { + xs: 'px-2.5 py-1.5 text-sm', + sm: 'px-3 py-2 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-4 py-2 text-base', + xl: 'px-6 py-3 text-base', + }[size] + return ( <button type={type} className={clsx( - 'font-md items-center justify-center rounded-md border border-transparent px-4 py-2 shadow-sm hover:transition-colors', + 'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50', + sizeClasses, color === 'green' && 'btn-primary text-white', color === 'red' && 'bg-red-400 text-white hover:bg-red-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', - color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300', + color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', + color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200', className )} + disabled={disabled} onClick={onClick} > {children} diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 796036d1..5b4962b7 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -13,7 +13,7 @@ export function PillButton(props: { return ( <button className={clsx( - 'cursor-pointer select-none rounded-full', + 'cursor-pointer select-none whitespace-nowrap rounded-full', selected ? ['text-white', color ?? 'bg-gray-700'] : 'bg-gray-100 hover:bg-gray-200', diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index 31995284..fc327b9f 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -6,10 +6,9 @@ import { Charity } from 'common/charity' import { useCharityTxns } from 'web/hooks/use-charity-txns' import { manaToUSD } from '../../../common/util/format' import { Row } from '../layout/row' -import { Col } from '../layout/col' export function CharityCard(props: { charity: Charity; match?: number }) { - const { charity, match } = props + const { charity } = props const { slug, photo, preview, id, tags } = charity const txns = useCharityTxns(id) @@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) { {raised > 0 && ( <> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900"> - <Col> + <Row className="items-baseline gap-1"> <span className="text-3xl font-semibold"> {formatUsd(raised)} </span> - <span>raised</span> - </Col> - {match && ( + raised + </Row> + {/* {match && ( <Col className="text-gray-500"> <span className="text-xl">+{formatUsd(match)}</span> <span className="">match</span> </Col> - )} + )} */} </Row> </> )} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 952d4034..45145c54 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -22,10 +22,13 @@ import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useUser } from 'web/hooks/use-user' import { useFollows } from 'web/hooks/use-follows' -import { trackCallback } from 'web/lib/service/analytics' +import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' -import { NEW_USER_GROUP_SLUGS } from 'common/group' +import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' +import { PillButton } from './buttons/pill-button' +import { sortBy } from 'lodash' +import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -36,14 +39,16 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const sortIndexes = [ { label: 'Newest', value: indexPrefix + 'contracts-newest' }, - { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, - { label: 'Most popular', value: indexPrefix + 'contracts-most-popular' }, + // { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, + { label: 'Most popular', value: indexPrefix + 'contracts-score' }, { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, + { label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, ] +export const DEFAULT_SORT = 'score' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' @@ -76,9 +81,24 @@ export function ContractSearch(props: { } = props const user = useUser() - const memberGroupSlugs = useMemberGroups(user?.id) - ?.map((g) => g.slug) - .filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) + const memberGroups = (useMemberGroups(user?.id) ?? []).filter( + (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug) + ) + const memberGroupSlugs = + memberGroups.length > 0 + ? memberGroups.map((g) => g.slug) + : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) + + const memberPillGroups = sortBy( + memberGroups.filter((group) => group.contractIds.length > 0), + (group) => group.contractIds.length + ).reverse() + + const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[] + + const pillGroups = + memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups + const follows = useFollows(user?.id) const { initialSort } = useInitialQueryAndSort(querySortOptions) @@ -86,34 +106,51 @@ export function ContractSearch(props: { .map(({ value }) => value) .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) ? initialSort - : querySortOptions?.defaultSort ?? 'most-popular' + : querySortOptions?.defaultSort ?? DEFAULT_SORT const [filter, setFilter] = useState<filter>( querySortOptions?.defaultFilter ?? 'open' ) + const pillsEnabled = !additionalFilter + + const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) + + const selectFilter = (pill: string | undefined) => () => { + setPillFilter(pill) + track('select search category', { category: pill ?? 'all' }) + } const { filters, numericFilters } = useMemo(() => { let filters = [ filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', - filter === 'personal' + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' ? // Show contracts in groups that the user is a member of - (memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? []) + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) // Show contracts created by users the user follows .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) // Show contracts bet on by users the user follows .concat( follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - // Show contracts bet on by the user ) - .concat(user ? `uniqueBettorIds:${user.id}` : []) : '', - additionalFilter?.creatorId - ? `creatorId:${additionalFilter.creatorId}` - : '', - additionalFilter?.groupSlug - ? `groupSlugs:${additionalFilter.groupSlug}` + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` : '', ].filter((f) => f) // Hack to make Algolia work. @@ -128,8 +165,9 @@ export function ContractSearch(props: { }, [ filter, Object.values(additionalFilter ?? {}).join(','), - (memberGroupSlugs ?? []).join(','), + memberGroupSlugs.join(','), (follows ?? []).join(','), + pillFilter, ]) const indexName = `${indexPrefix}contracts-${sort}` @@ -160,12 +198,11 @@ export function ContractSearch(props: { className="!select !select-bordered" value={filter} onChange={(e) => setFilter(e.target.value as filter)} - onBlur={trackCallback('select search filter')} + onBlur={trackCallback('select search filter', { filter })} > <option value="open">Open</option> <option value="closed">Closed</option> <option value="resolved">Resolved</option> - <option value="personal">For you</option> <option value="all">All</option> </select> {!hideOrderSelector && ( @@ -174,7 +211,7 @@ export function ContractSearch(props: { classNames={{ select: '!select !select-bordered', }} - onBlur={trackCallback('select search sort')} + onBlur={trackCallback('select search sort', { sort })} /> )} <Configure @@ -187,11 +224,52 @@ export function ContractSearch(props: { <Spacer h={3} /> - {/*<Spacer h={4} />*/} + {pillsEnabled && ( + <Row className="scrollbar-hide items-start gap-2 overflow-x-auto"> + <PillButton + key={'all'} + selected={pillFilter === undefined} + onSelect={selectFilter(undefined)} + > + All + </PillButton> + <PillButton + key={'personal'} + selected={pillFilter === 'personal'} + onSelect={selectFilter('personal')} + > + {user ? 'For you' : 'Featured'} + </PillButton> + + {user && ( + <PillButton + key={'your-bets'} + selected={pillFilter === 'your-bets'} + onSelect={selectFilter('your-bets')} + > + Your bets + </PillButton> + )} + + {pillGroups.map(({ name, slug }) => { + return ( + <PillButton + key={slug} + selected={pillFilter === slug} + onSelect={selectFilter(slug)} + > + {name} + </PillButton> + ) + })} + </Row> + )} + + <Spacer h={3} /> {filter === 'personal' && (follows ?? []).length === 0 && - (memberGroupSlugs ?? []).length === 0 ? ( + memberGroupSlugs.length === 0 ? ( <>You're not following anyone, nor in any of your own groups yet.</> ) : ( <ContractSearchInner diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index b2f839e9..f9db0cd9 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -2,16 +2,17 @@ import clsx from 'clsx' import dayjs from 'dayjs' import { useState } from 'react' import Textarea from 'react-expanding-textarea' -import { CATEGORY_LIST } from '../../../common/categories' -import { Contract } from 'common/contract' -import { parseTags, exhibitExts } from 'common/util/parse' +import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' +import { exhibitExts, parseTags } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' import { updateContract } from 'web/lib/firebase/contracts' import { Row } from '../layout/row' -import { TagsList } from '../tags-list' import { Content } from '../editor' -import { Editor } from '@tiptap/react' +import { TextEditor, useTextEditor } from 'web/components/editor' +import { Button } from '../button' +import { Spacer } from '../layout/spacer' +import { Editor, Content as ContentType } from '@tiptap/react' export function ContractDescription(props: { contract: Contract @@ -19,20 +20,36 @@ export function ContractDescription(props: { className?: string }) { const { contract, isCreator, className } = props - const descriptionTimestamp = () => `${dayjs().format('MMM D, h:mma')}: ` const isAdmin = useAdmin() + return ( + <div className={clsx('mt-2 text-gray-700', className)}> + {isCreator || isAdmin ? ( + <RichEditContract contract={contract} isAdmin={isAdmin && !isCreator} /> + ) : ( + <Content content={contract.description} /> + )} + </div> + ) +} - const desc = contract.description ?? '' +function editTimestamp() { + return `${dayjs().format('MMM D, h:mma')}: ` +} - // Append the new description (after a newline) - async function saveDescription(newText: string) { - const editor = new Editor({ content: desc, extensions: exhibitExts }) - editor - .chain() - .focus('end') - .insertContent('<br /><br />') - .insertContent(newText.trim()) - .run() +function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { + const { contract, isAdmin } = props + const [editing, setEditing] = useState(false) + const [editingQ, setEditingQ] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + max: MAX_DESCRIPTION_LENGTH, + defaultValue: contract.description, + disabled: isSubmitting, + }) + + async function saveDescription() { + if (!editor) return const tags = parseTags( `${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}` @@ -46,76 +63,94 @@ export function ContractDescription(props: { }) } - const { tags } = contract - const categories = tags.filter((tag) => - CATEGORY_LIST.includes(tag.toLowerCase()) - ) - - return ( - <div - className={clsx( - 'mt-2 whitespace-pre-line break-words text-gray-700', - className - )} - > - <Content content={desc} /> - - {categories.length > 0 && ( - <div className="mt-4"> - <TagsList tags={categories} noLabel /> - </div> - )} - - <br /> - - {isCreator && ( - <EditContract - // Note: Because descriptionTimestamp is called once, later edits use - // a stale timestamp. Ideally this is a function that gets called when - // isEditing is set to true. - text={descriptionTimestamp()} - onSave={saveDescription} - buttonText="Add to description" - /> - )} - {isAdmin && ( - <EditContract - text={contract.question} - onSave={(question) => updateContract(contract.id, { question })} - buttonText="ADMIN: Edit question" - /> - )} - {/* {isAdmin && ( - <EditContract - text={contract.createdTime.toString()} - onSave={(time) => - updateContract(contract.id, { createdTime: Number(time) }) - } - buttonText="ADMIN: Edit createdTime" - /> - )} */} - </div> + return editing ? ( + <> + <TextEditor editor={editor} upload={upload} /> + <Spacer h={2} /> + <Row className="gap-2"> + <Button + onClick={async () => { + setIsSubmitting(true) + await saveDescription() + setEditing(false) + setIsSubmitting(false) + }} + > + Save + </Button> + <Button color="gray" onClick={() => setEditing(false)}> + Cancel + </Button> + </Row> + </> + ) : ( + <> + <Content content={contract.description} /> + <Spacer h={2} /> + <Row className="items-center gap-2"> + {isAdmin && 'Admin: '} + <Button + color="gray" + size="xs" + onClick={() => { + setEditing(true) + editor + ?.chain() + .setContent(contract.description) + .focus('end') + .insertContent(`<p>${editTimestamp()}</p>`) + .run() + }} + > + Edit description + </Button> + <Button color="gray" size="xs" onClick={() => setEditingQ(true)}> + Edit question + </Button> + </Row> + <EditQuestion + contract={contract} + editing={editingQ} + setEditing={setEditingQ} + /> + </> ) } -function EditContract(props: { - text: string - onSave: (newText: string) => void - buttonText: string +function EditQuestion(props: { + contract: Contract + editing: boolean + setEditing: (editing: boolean) => void }) { - const [text, setText] = useState(props.text) - const [editing, setEditing] = useState(false) - const onSave = (newText: string) => { + const { contract, editing, setEditing } = props + const [text, setText] = useState(contract.question) + + function questionChanged(oldQ: string, newQ: string) { + return `<p>${editTimestamp()}<s>${oldQ}</s> → ${newQ}</p>` + } + + function joinContent(oldContent: ContentType, newContent: string) { + const editor = new Editor({ content: oldContent, extensions: exhibitExts }) + editor.chain().focus('end').insertContent(newContent).run() + return editor.getJSON() + } + + const onSave = async (newText: string) => { setEditing(false) - setText(props.text) // Reset to original text - props.onSave(newText) + await updateContract(contract.id, { + question: newText, + description: joinContent( + contract.description, + questionChanged(contract.question, newText) + ), + }) } return editing ? ( <div className="mt-4"> <Textarea className="textarea textarea-bordered mb-1 h-24 w-full resize-none" - rows={3} + rows={2} value={text} onChange={(e) => setText(e.target.value || '')} autoFocus @@ -130,28 +165,11 @@ function EditContract(props: { }} /> <Row className="gap-2"> - <button - className="btn btn-neutral btn-outline btn-sm" - onClick={() => onSave(text)} - > - Save - </button> - <button - className="btn btn-error btn-outline btn-sm" - onClick={() => setEditing(false)} - > + <Button onClick={() => onSave(text)}>Save</Button> + <Button color="gray" onClick={() => setEditing(false)}> Cancel - </button> + </Button> </Row> </div> - ) : ( - <Row> - <button - className="btn btn-neutral btn-outline btn-xs mt-4" - onClick={() => setEditing(true)} - > - {props.buttonText} - </button> - </Row> - ) + ) : null } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index b4d67520..83c291c7 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -11,7 +11,7 @@ import { UserLink } from '../user-page' import { Contract, contractMetrics, - contractPool, + contractPath, updateContract, } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' @@ -22,17 +22,19 @@ import { useState } from 'react' import { ContractInfoDialog } from './contract-info-dialog' import { Bet } from 'common/bet' import NewContractBadge from '../new-contract-badge' -import { CATEGORY_LIST } from 'common/categories' -import { TagsList } from '../tags-list' import { UserFollowButton } from '../follow-button' -import { groupPath } from 'web/lib/firebase/groups' -import { SiteLink } from 'web/components/site-link' import { DAY_MS } from 'common/util/time' -import { useGroupsWithContract } from 'web/hooks/use-group' import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' +import { ENV_CONFIG } from 'common/envs/constants' +import { Button } from 'web/components/button' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { ContractGroupsList } from 'web/components/groups/contract-groups-list' +import { SiteLink } from 'web/components/site-link' +import { groupPath } from 'web/lib/firebase/groups' export type ShowTime = 'resolve-date' | 'close-date' @@ -46,15 +48,12 @@ export function MiscDetails(props: { volume, volume24Hours, closeTime, - tags, isResolved, createdTime, resolutionTime, + groupLinks, } = contract - // Show at most one category that this contract is tagged by - const categories = CATEGORY_LIST.filter((category) => - tags.map((t) => t.toLowerCase()).includes(category) - ).slice(0, 1) + const isNew = createdTime > Date.now() - DAY_MS && !isResolved return ( @@ -76,13 +75,21 @@ export function MiscDetails(props: { {fromNow(resolutionTime || 0)} </Row> ) : volume > 0 || !isNew ? ( - <Row>{contractPool(contract)} pool</Row> + <Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> ) : ( <NewContractBadge /> )} - {categories.length > 0 && ( - <TagsList className="text-gray-400" tags={categories} noLabel /> + {groupLinks && groupLinks.length > 0 && ( + <SiteLink + href={groupPath(groupLinks[0].slug)} + className="text-sm text-gray-400" + > + <Row className={'line-clamp-1 flex-wrap items-center '}> + <UserGroupIcon className="mx-1 mb-0.5 inline h-4 w-4 shrink-0" /> + {groupLinks[0].name} + </Row> + </SiteLink> )} </Row> ) @@ -130,34 +137,15 @@ export function ContractDetails(props: { disabled?: boolean }) { const { contract, bets, isCreator, disabled } = props - const { closeTime, creatorName, creatorUsername, creatorId } = contract + const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } = + contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => { - return g2.createdTime - g1.createdTime - }) - const user = useUser() - - const groupsUserIsMemberOf = groups - ? groups.filter((g) => g.memberIds.includes(contract.creatorId)) - : [] - const groupsUserIsCreatorOf = groups - ? groups.filter((g) => g.creatorId === contract.creatorId) - : [] - - // Priorities for which group the contract belongs to: - // In order of created most recently - // Group that the contract owner created - // Group the contract owner is a member of - // Any group the contract is in const groupToDisplay = - groupsUserIsCreatorOf.length > 0 - ? groupsUserIsCreatorOf[0] - : groupsUserIsMemberOf.length > 0 - ? groupsUserIsMemberOf[0] - : groups - ? groups[0] - : undefined + groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null + const user = useUser() + const [open, setOpen] = useState(false) + return ( <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="items-center gap-2"> @@ -178,16 +166,34 @@ export function ContractDetails(props: { )} {!disabled && <UserFollowButton userId={creatorId} small />} </Row> - {groupToDisplay ? ( - <Row className={'line-clamp-1 mt-1 max-w-[200px]'}> - <SiteLink href={`${groupPath(groupToDisplay.slug)}`}> - <UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" /> - <span>{groupToDisplay.name}</span> - </SiteLink> - </Row> - ) : ( - <div /> - )} + <Row> + <Button + size={'xs'} + className={'max-w-[200px]'} + color={'gray-white'} + onClick={() => setOpen(!open)} + > + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className={'line-clamp-1'}> + {groupToDisplay ? groupToDisplay.name : 'No group'} + </span> + </Row> + </Button> + </Row> + <Modal open={open} setOpen={setOpen} size={'md'}> + <Col + className={ + 'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6' + } + > + <ContractGroupsList + groupLinks={groupLinks ?? []} + contract={contract} + user={user} + /> + </Col> + </Modal> {(!!closeTime || !!resolvedDate) && ( <Row className="items-center gap-1"> @@ -222,9 +228,12 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{volumeLabel}</div> </Row> <ShareIconButton - contract={contract} + copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${ + user?.username && contract.creatorUsername !== user?.username + ? '?referrer=' + user?.username + : '' + }`} toastClassName={'sm:-left-40 -left-24 min-w-[250%]'} - username={user?.username} /> {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} @@ -321,12 +330,13 @@ function EditableCloseDate(props: { Done </button> ) : ( - <button - className="btn btn-xs btn-ghost" + <Button + size={'xs'} + color={'gray-white'} onClick={() => setIsEditingCloseTime(true)} > - <PencilIcon className="mr-2 inline h-4 w-4" /> Edit - </button> + <PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit + </Button> ))} </> ) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index b5ecea15..d976253f 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -16,11 +16,10 @@ import { ShareEmbedButton } from '../share-embed-button' import { Title } from '../title' import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' -import { TagsInput } from 'web/components/tags-input' import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props @@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </tbody> </table> - <div>Tags</div> - <TagsInput contract={contract} /> - <div /> {contract.mechanism === 'cpmm-1' && !contract.resolution && ( <LiquidityPanel contract={contract} /> )} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx new file mode 100644 index 00000000..deb9b857 --- /dev/null +++ b/web/components/contract/contract-leaderboard.tsx @@ -0,0 +1,141 @@ +import { Bet } from 'common/bet' +import { Comment } from 'common/comment' +import { resolvedPayout } from 'common/calculate' +import { Contract } from 'common/contract' +import { formatMoney } from 'common/util/format' +import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' +import { useState, useMemo, useEffect } from 'react' +import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { useUserById } from 'web/hooks/use-user' +import { listUsers, User } from 'web/lib/firebase/users' +import { FeedBet } from '../feed/feed-bets' +import { FeedComment } from '../feed/feed-comments' +import { Spacer } from '../layout/spacer' +import { Leaderboard } from '../leaderboard' +import { Title } from '../title' + +export function ContractLeaderboard(props: { + contract: Contract + bets: Bet[] +}) { + const { contract, bets } = props + const [users, setUsers] = useState<User[]>() + + const { userProfits, top5Ids } = useMemo(() => { + // Create a map of userIds to total profits (including sales) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) + const betsByUser = groupBy(openBets, 'userId') + + const userProfits = mapValues(betsByUser, (bets) => + sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) + ) + // Find the 5 users with the most profits + const top5Ids = Object.entries(userProfits) + .sort(([_i1, p1], [_i2, p2]) => p2 - p1) + .filter(([, p]) => p > 0) + .slice(0, 5) + .map(([id]) => id) + return { userProfits, top5Ids } + }, [contract, bets]) + + useEffect(() => { + if (top5Ids.length > 0) { + listUsers(top5Ids).then((users) => { + const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) + setUsers(sortedUsers) + }) + } + }, [userProfits, top5Ids]) + + return users && users.length > 0 ? ( + <Leaderboard + title="🏅 Top traders" + users={users || []} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(userProfits[user.id] || 0), + }, + ]} + className="mt-12 max-w-sm" + /> + ) : null +} + +export function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + tips: CommentTipMap +}) { + const { contract, bets, comments, tips } = props + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record<string, number> = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = useUserById(betsById[topBetId]?.userId) + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + + return ( + <div className="mt-12 max-w-sm"> + {topCommentId && profitById[topCommentId] > 0 && ( + <> + <Title text="💬 Proven correct" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + tips={tips[topCommentId]} + betsBySameUser={[betsById[topCommentId]]} + truncate={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {commentsById[topCommentId].userName} made{' '} + {formatMoney(profitById[topCommentId] || 0)}! + </div> + <Spacer h={16} /> + </> + )} + + {/* If they're the same, only show the comment; otherwise show both */} + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( + <> + <Title text="💸 Smartest money" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedBet + contract={contract} + bet={betsById[topBetId]} + hideOutcome={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + </div> + </> + )} + </div> + ) +} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index a9d26e2e..98440ec8 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -7,7 +7,6 @@ import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' -import { getMappedValue } from 'common/pseudo-numeric' import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { @@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const f = getMappedValue(contract) + const f: (p: number) => number = isBinary + ? (p) => p + : isLogScale + ? (p) => p * Math.log10(contract.max - contract.min + 1) + : (p) => p * (contract.max - contract.min) + contract.min const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) @@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const points: { x: Date; y: number }[] = [] const s = isBinary ? 100 : 1 - const c = isLogScale && contract.min === 0 ? 1 : 0 for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: s * probs[i] + c } + points[points.length] = { x: times[i], y: s * probs[i] } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: s * probs[i] + c, + y: s * probs[i], } } } @@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const formatter = isBinary ? formatPercent + : isLogScale + ? (x: DatumValue) => + formatLargeNumber(10 ** +x.valueOf() + contract.min - 1) : (x: DatumValue) => formatLargeNumber(+x.valueOf()) return ( @@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { yScale={ isBinary ? { min: 0, max: 100, type: 'linear' } - : { - min: contract.min + c, - max: contract.max + c, - type: contract.isLogScale ? 'log' : 'linear', + : isLogScale + ? { + min: 0, + max: Math.log10(contract.max - contract.min + 1), + type: 'linear', } + : { min: contract.min, max: contract.max, type: 'linear' } } yFormat={formatter} gridYValues={yTickValues} @@ -143,6 +150,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { enableSlices="x" enableGridX={!!width && width >= 800} enableArea + areaBaselineValue={isBinary || isLogScale ? 0 : contract.min} margin={{ top: 20, right: 20, bottom: 25, left: 40 }} animate={false} sliceTooltip={SliceTooltip} diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index ab6dd66f..4ce4140d 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -3,58 +3,63 @@ import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' -import { Contract } from 'common/contract' import { copyToClipboard } from 'web/lib/util/copy' -import { contractPath } from 'web/lib/firebase/contracts' -import { ENV_CONFIG } from 'common/envs/constants' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' - -function copyContractUrl(contract: Contract) { - copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) -} +import { Row } from './layout/row' export function CopyLinkButton(props: { - contract: Contract + url: string + displayUrl?: string + tracking?: string buttonClassName?: string toastClassName?: string }) { - const { contract, buttonClassName, toastClassName } = props + const { url, displayUrl, tracking, buttonClassName, toastClassName } = props return ( - <Menu - as="div" - className="relative z-10 flex-shrink-0" - onMouseUp={() => { - copyContractUrl(contract) - track('copy share link') - }} - > - <Menu.Button - className={clsx( - 'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white', - buttonClassName - )} - > - <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> - Copy link - </Menu.Button> + <Row className="w-full"> + <input + className="input input-bordered flex-1 rounded-r-none text-gray-500" + readOnly + type="text" + value={displayUrl ?? url} + /> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" + <Menu + as="div" + className="relative z-10 flex-shrink-0" + onMouseUp={() => { + copyToClipboard(url) + track(tracking ?? 'copy share link') + }} > - <Menu.Items> - <Menu.Item> - <ToastClipboard className={toastClassName} /> - </Menu.Item> - </Menu.Items> - </Transition> - </Menu> + <Menu.Button + className={clsx( + 'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white', + buttonClassName + )} + > + <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> + Copy link + </Menu.Button> + + <Transition + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <Menu.Items> + <Menu.Item> + <ToastClipboard className={toastClassName} /> + </Menu.Item> + </Menu.Items> + </Transition> + </Menu> + </Row> ) } diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index a9161ac6..1b8ac11e 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,10 +1,12 @@ import Link from 'next/link' +import { useRouter } from 'next/router' import clsx from 'clsx' import { firebaseLogin, User } from 'web/lib/firebase/users' import React from 'react' export const createButtonStyle = 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' + export const CreateQuestionButton = (props: { user: User | null | undefined overrideText?: string @@ -15,17 +17,23 @@ export const CreateQuestionButton = (props: { 'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700' const { user, overrideText, className, query } = props + const router = useRouter() return ( <div className={clsx('flex justify-center', className)}> {user ? ( <Link href={`/create${query ? query : ''}`} passHref> <button className={clsx(gradient, createButtonStyle)}> - {overrideText ? overrideText : 'Create a question'} + {overrideText ? overrideText : 'Create a market'} </button> </Link> ) : ( <button - onClick={firebaseLogin} + onClick={async () => { + // login, and then reload the page, to hit any SSR redirect (e.g. + // redirecting from / to /home for logged in users) + await firebaseLogin() + router.replace(router.asPath) + }} className={clsx(gradient, createButtonStyle)} > Sign in diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 4b3e2cce..4dfddac9 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -12,16 +12,24 @@ import StarterKit from '@tiptap/starter-kit' import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import clsx from 'clsx' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Linkify } from './linkify' import { uploadImage } from 'web/lib/firebase/storage' import { useMutation } from 'react-query' import { exhibitExts } from 'common/util/parse' import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' +import Iframe from 'common/util/tiptap-iframe' +import { CodeIcon, PhotographIcon } from '@heroicons/react/solid' +import { Modal } from './layout/modal' +import { Col } from './layout/col' +import { Button } from './button' +import { Row } from './layout/row' +import { Spacer } from './layout/spacer' const proseClass = clsx( - 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none' + 'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed', + 'font-light prose-a:font-light prose-blockquote:font-light' ) export function useTextEditor(props: { @@ -34,7 +42,7 @@ export function useTextEditor(props: { const editorClass = clsx( proseClass, - 'box-content min-h-[6em] textarea textarea-bordered' + 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' ) const editor = useEditor({ @@ -55,6 +63,7 @@ export function useTextEditor(props: { class: clsx('no-underline !text-indigo-700', linkClass), }, }), + Iframe, ], content: defaultValue, }) @@ -68,12 +77,19 @@ export function useTextEditor(props: { (file) => file.type.startsWith('image') ) - if (!imageFiles.length) { - return // if no files pasted, use default paste handler + if (imageFiles.length) { + event.preventDefault() + upload.mutate(imageFiles) } - event.preventDefault() - upload.mutate(imageFiles) + // If the pasted content is iframe code, directly inject it + const text = event.clipboardData?.getData('text/plain').trim() ?? '' + if (isValidIframe(text)) { + editor.chain().insertContent(text).run() + return true // Prevent the code from getting pasted as text + } + + return // Otherwise, use default paste handler }, }, }) @@ -85,31 +101,76 @@ export function useTextEditor(props: { return { editor, upload } } +function isValidIframe(text: string) { + return /^<iframe.*<\/iframe>$/.test(text) +} + export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> }) { const { editor, upload } = props + const [iframeOpen, setIframeOpen] = useState(false) return ( <> {/* hide placeholder when focused */} - <div className="w-full [&:focus-within_p.is-empty]:before:content-none"> + <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> {editor && ( <FloatingMenu editor={editor} - className="w-full text-sm text-slate-300" + className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')} > - Type <em>*anything*</em> or even paste or{' '} + Type <em>*markdown*</em>. Paste or{' '} <FileUploadButton className="link text-blue-300" onFiles={upload.mutate} > - upload an image - </FileUploadButton> + upload + </FileUploadButton>{' '} + images! </FloatingMenu> )} - <EditorContent editor={editor} /> + <div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <EditorContent editor={editor} /> + {/* Spacer element to match the height of the toolbar */} + <div className="py-2" aria-hidden="true"> + {/* Matches height of button in toolbar (1px border + 36px content height) */} + <div className="py-px"> + <div className="h-9" /> + </div> + </div> + </div> + + {/* Toolbar, with buttons for image and embeds */} + <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> + <div className="flex items-center space-x-5"> + <div className="flex items-center"> + <FileUploadButton + onFiles={upload.mutate} + className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" + > + <PhotographIcon className="h-5 w-5" aria-hidden="true" /> + <span className="sr-only">Upload an image</span> + </FileUploadButton> + </div> + <div className="flex items-center"> + <button + type="button" + onClick={() => setIframeOpen(true)} + className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" + > + <IframeModal + editor={editor} + open={iframeOpen} + setOpen={setIframeOpen} + /> + <CodeIcon className="h-5 w-5" aria-hidden="true" /> + <span className="sr-only">Embed an iframe</span> + </button> + </div> + </div> + </div> </div> {upload.isLoading && <span className="text-xs">Uploading image...</span>} {upload.isError && ( @@ -119,9 +180,69 @@ export function TextEditor(props: { ) } +function IframeModal(props: { + editor: Editor | null + open: boolean + setOpen: (open: boolean) => void +}) { + const { editor, open, setOpen } = props + const [embedCode, setEmbedCode] = useState('') + const valid = isValidIframe(embedCode) + + return ( + <Modal open={open} setOpen={setOpen}> + <Col className="gap-2 rounded bg-white p-6"> + <label + htmlFor="embed" + className="block text-sm font-medium text-gray-700" + > + Embed a market, Youtube video, etc. + </label> + <input + type="text" + name="embed" + id="embed" + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder='e.g. <iframe src="..."></iframe>' + value={embedCode} + onChange={(e) => setEmbedCode(e.target.value)} + /> + + {/* Preview the embed if it's valid */} + {valid ? <RichContent content={embedCode} /> : <Spacer h={2} />} + + <Row className="gap-2"> + <Button + disabled={!valid} + onClick={() => { + if (editor && valid) { + editor.chain().insertContent(embedCode).run() + setEmbedCode('') + setOpen(false) + } + }} + > + Embed + </Button> + <Button + color="gray" + onClick={() => { + setEmbedCode('') + setOpen(false) + }} + > + Cancel + </Button> + </Row> + </Col> + </Modal> + ) +} + const useUploadMutation = (editor: Editor | null) => useMutation( (files: File[]) => + // TODO: Images should be uploaded under a particular username Promise.all(files.map((file) => uploadImage('default', file))), { onSuccess(urls) { @@ -136,7 +257,7 @@ const useUploadMutation = (editor: Editor | null) => } ) -function RichContent(props: { content: JSONContent }) { +function RichContent(props: { content: JSONContent | string }) { const { content } = props const editor = useEditor({ editorProps: { attributes: { class: proseClass } }, @@ -153,7 +274,9 @@ function RichContent(props: { content: JSONContent }) { export function Content(props: { content: JSONContent | string }) { const { content } = props return typeof content === 'string' ? ( - <Linkify text={content} /> + <div className="whitespace-pre-line font-light leading-relaxed"> + <Linkify text={content} /> + </div> ) : ( <RichContent content={content} /> ) diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 5c3be539..aabb1081 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,18 +1,13 @@ import { Answer } from 'common/answer' import { Bet } from 'common/bet' import { Comment } from 'common/comment' -import { formatPercent } from 'common/util/format' import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' -import { Modal } from 'web/components/layout/modal' -import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import clsx from 'clsx' -import { tradingAllowed } from 'web/lib/firebase/contracts' -import { BuyButton } from 'web/components/yes-no-selector' import { CommentInput, CommentRepliesList, @@ -23,7 +18,6 @@ import { useRouter } from 'next/router' import { groupBy } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' -import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { CommentTipMap } from 'web/hooks/use-tip-txns' export function FeedAnswerCommentGroup(props: { @@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: { const { username, avatarUrl, name, text } = answer const [replyToUsername, setReplyToUsername] = useState('') - const [open, setOpen] = useState(false) const [showReply, setShowReply] = useState(false) const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [highlighted, setHighlighted] = useState(false) @@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: { const commentsList = comments.filter( (comment) => comment.answerOutcome === answer.number.toString() ) - const thisAnswerProb = getDpmOutcomeProbability( - contract.totalShares, - answer.id - ) - const probPercent = formatPercent(thisAnswerProb) const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const isFreeResponseContractPage = !!commentsByCurrentUser @@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: { }, [answerElementId, router.asPath]) return ( - <Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}> - <Modal open={open} setOpen={setOpen}> - <AnswerBetPanel - answer={answer} - contract={contract} - closePanel={() => setOpen(false)} - className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6" - isModal={true} - /> - </Modal> - + <Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}> <Row className={clsx( - 'my-4 flex gap-3 space-x-3 transition-all duration-1000', + 'flex gap-3 space-x-3 pt-4 transition-all duration-1000', highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' )} id={answerElementId} > - <div className="px-1"> - <Avatar username={username} avatarUrl={avatarUrl} /> - </div> + <Avatar username={username} avatarUrl={avatarUrl} /> + <Col className="min-w-0 flex-1 lg:gap-1"> <div className="text-sm text-gray-500"> <UserLink username={username} name={name} /> answered @@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: { /> </div> - <Col className="align-items justify-between gap-4 sm:flex-row"> + <Col className="align-items justify-between gap-2 sm:flex-row"> <span className="whitespace-pre-line text-lg"> <Linkify text={text} /> </span> - <Row className="items-center justify-center gap-4"> - {isFreeResponseContractPage && ( - <div className={'sm:hidden'}> - <button - className={ - 'text-xs font-bold text-gray-500 hover:underline' - } - onClick={() => scrollAndOpenReplyInput(undefined, answer)} - > - Reply - </button> - </div> - )} - - <div className={'align-items flex w-full justify-end gap-4 '}> - <span - className={clsx( - 'text-2xl', - tradingAllowed(contract) ? 'text-primary' : 'text-gray-500' - )} + {isFreeResponseContractPage && ( + <div className={'sm:hidden'}> + <button + className={'text-xs font-bold text-gray-500 hover:underline'} + onClick={() => scrollAndOpenReplyInput(undefined, answer)} > - {probPercent} - </span> - <BuyButton - className={clsx( - 'btn-sm flex-initial !px-6 sm:flex', - tradingAllowed(contract) ? '' : '!hidden' - )} - onClick={() => setOpen(true)} - /> + Reply + </button> </div> - </Row> + )} </Col> {isFreeResponseContractPage && ( <div className={'justify-initial hidden sm:block'}> @@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: { /> {showReply && ( - <div className={'ml-6 pt-4'}> + <div className={'ml-6'}> <span - className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> <CommentInput diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 1520e57c..408404ba 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -93,6 +93,24 @@ export function BetStatusText(props: { bet.fills?.some((fill) => fill.matchedBetId === null)) ?? false + const fromProb = + hadPoolMatch || isFreeResponse + ? isPseudoNumeric + ? formatNumericProbability(bet.probBefore, contract) + : formatPercent(bet.probBefore) + : isPseudoNumeric + ? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract) + : formatPercent(bet.limitProb ?? bet.probBefore) + + const toProb = + hadPoolMatch || isFreeResponse + ? isPseudoNumeric + ? formatNumericProbability(bet.probAfter, contract) + : formatPercent(bet.probAfter) + : isPseudoNumeric + ? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract) + : formatPercent(bet.limitProb ?? bet.probAfter) + return ( <div className="text-sm text-gray-500"> {bettor ? ( @@ -112,14 +130,9 @@ export function BetStatusText(props: { contract={contract} truncate="short" />{' '} - {isPseudoNumeric - ? ' than ' + formatNumericProbability(bet.probAfter, contract) - : ' at ' + - formatPercent( - hadPoolMatch || isFreeResponse - ? bet.probAfter - : bet.limitProb ?? bet.probAfter - )} + {fromProb === toProb + ? `at ${fromProb}` + : `from ${fromProb} to ${toProb}`} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 195c5343..f4c6eb74 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -70,7 +70,7 @@ export function FeedCommentThread(props: { if (showReply && inputRef) inputRef.focus() }, [inputRef, showReply]) return ( - <div className={'w-full flex-col pr-1'}> + <Col className={'w-full gap-3 pr-1'}> <span className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" aria-hidden="true" @@ -86,7 +86,7 @@ export function FeedCommentThread(props: { scrollAndOpenReplyInput={scrollAndOpenReplyInput} /> {showReply && ( - <div className={'-pb-2 ml-6 flex flex-col pt-5'}> + <Col className={'-pb-2 ml-6'}> <span className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" @@ -106,9 +106,9 @@ export function FeedCommentThread(props: { setReplyToUsername('') }} /> - </div> + </Col> )} - </div> + </Col> ) } @@ -142,7 +142,7 @@ export function CommentRepliesList(props: { id={comment.id} className={clsx( 'relative', - !treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6' + !treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6' )} > {/*draw a gray line from the comment to the left:*/} diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index ff5f5440..ea8302b8 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -23,6 +23,7 @@ import BetRow from '../bet-row' import { Avatar } from '../avatar' import { ActivityItem } from './activity-items' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' +import { useUser } from 'web/hooks/use-user' import { trackClick } from 'web/lib/firebase/tracking' import { DAY_MS } from 'common/util/time' import NewContractBadge from '../new-contract-badge' @@ -118,6 +119,7 @@ export function FeedQuestion(props: { const { volumeLabel } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY' const isNew = createdTime > Date.now() - DAY_MS && !isResolved + const user = useUser() return ( <div className={'flex gap-2'}> @@ -149,7 +151,7 @@ export function FeedQuestion(props: { href={ props.contractPath ? props.contractPath : contractPath(contract) } - onClick={() => trackClick(contract.id)} + onClick={() => user && trackClick(user.id, contract.id)} className="text-lg text-indigo-700 sm:text-xl" > {question} diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 7ce73cf8..a19ab6af 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -7,6 +7,7 @@ import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' +import { searchInAny } from 'common/util/parse' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void @@ -35,8 +36,7 @@ export function FilterSelectUsers(props: { return ( !selectedUsers.map((user) => user.name).includes(user.name) && !ignoreUserIds.includes(user.id) && - (user.name.toLowerCase().includes(query.toLowerCase()) || - user.username.toLowerCase().includes(query.toLowerCase())) + searchInAny(query, user.name, user.username) ) }) ) diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx new file mode 100644 index 00000000..423cbb97 --- /dev/null +++ b/web/components/groups/contract-groups-list.tsx @@ -0,0 +1,73 @@ +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { GroupLinkItem } from 'web/pages/groups' +import { XIcon } from '@heroicons/react/outline' +import { Button } from 'web/components/button' +import { GroupSelector } from 'web/components/groups/group-selector' +import { + addContractToGroup, + removeContractFromGroup, +} from 'web/lib/firebase/groups' +import { User } from 'common/user' +import { Contract } from 'common/contract' +import { SiteLink } from 'web/components/site-link' +import { GroupLink } from 'common/group' +import { useGroupsWithContract } from 'web/hooks/use-group' + +export function ContractGroupsList(props: { + groupLinks: GroupLink[] + contract: Contract + user: User | null | undefined +}) { + const { groupLinks, user, contract } = props + const groups = useGroupsWithContract(contract) + return ( + <Col className={'gap-2'}> + <span className={'text-xl text-indigo-700'}> + <SiteLink href={'/groups/'}>Groups</SiteLink> + </span> + {user && ( + <Col className={'ml-2 items-center justify-between sm:flex-row'}> + <span>Add to: </span> + <GroupSelector + options={{ + showSelector: true, + showLabel: false, + ignoreGroupIds: groupLinks.map((g) => g.groupId), + }} + setSelectedGroup={(group) => + group && addContractToGroup(group, contract, user.id) + } + selectedGroup={undefined} + creator={user} + /> + </Col> + )} + {groups.length === 0 && ( + <Col className="ml-2 h-full justify-center text-gray-500"> + No groups yet... + </Col> + )} + {groups.map((group) => ( + <Row + key={group.id} + className={clsx('items-center justify-between gap-2 p-2')} + > + <Row className="line-clamp-1 items-center gap-2"> + <GroupLinkItem group={group} /> + </Row> + {user && group.memberIds.includes(user.id) && ( + <Button + color={'gray-white'} + size={'xs'} + onClick={() => removeContractFromGroup(group, contract)} + > + <XIcon className="h-4 w-4 text-gray-500" /> + </Button> + )} + </Row> + ))} + </Col> + ) +} diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index ea1597f2..e6270a4d 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -11,25 +11,28 @@ import { CreateGroupButton } from 'web/components/groups/create-group-button' import { useState } from 'react' import { useMemberGroups } from 'web/hooks/use-group' import { User } from 'common/user' +import { searchInAny } from 'common/util/parse' export function GroupSelector(props: { - selectedGroup?: Group + selectedGroup: Group | undefined setSelectedGroup: (group: Group) => void creator: User | null | undefined - showSelector?: boolean + options: { + showSelector: boolean + showLabel: boolean + ignoreGroupIds?: string[] + } }) { - const { selectedGroup, setSelectedGroup, creator, showSelector } = props + const { selectedGroup, setSelectedGroup, creator, options } = props const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) - + const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - const memberGroups = useMemberGroups(creator?.id) - const filteredGroups = memberGroups - ? query === '' - ? memberGroups - : memberGroups.filter((group) => { - return group.name.toLowerCase().includes(query.toLowerCase()) - }) - : [] + const memberGroups = (useMemberGroups(creator?.id) ?? []).filter( + (group) => !ignoreGroupIds?.includes(group.id) + ) + const filteredGroups = memberGroups.filter((group) => + searchInAny(query, group.name) + ) if (!showSelector || !creator) { return ( @@ -56,19 +59,20 @@ export function GroupSelector(props: { nullable={true} className={'text-sm'} > - {({ open }) => ( + {() => ( <> - {!open && setQuery('')} - <Combobox.Label className="label justify-start gap-2 text-base"> - Add to Group - <InfoTooltip text="Question will be displayed alongside the other questions in the group." /> - </Combobox.Label> + {showLabel && ( + <Combobox.Label className="label justify-start gap-2 text-base"> + Add to Group + <InfoTooltip text="Question will be displayed alongside the other questions in the group." /> + </Combobox.Label> + )} <div className="relative mt-2"> <Combobox.Input - className="w-full rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 " + className="w-60 rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 " onChange={(event) => setQuery(event.target.value)} displayValue={(group: Group) => group && group.name} - placeholder={'None'} + placeholder={'E.g. Science, Politics'} /> <Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"> <SelectorIcon diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index f3ae77a2..bb94c9ed 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -11,13 +11,15 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { firebaseLogin } from 'web/lib/firebase/users' -import { GroupLink } from 'web/pages/groups' +import { GroupLinkItem } from 'web/pages/groups' import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) - const groups = useMemberGroups(user.id) + const groups = useMemberGroups(user.id, undefined, { + by: 'mostRecentChatActivityTime', + }) return ( <> @@ -75,7 +77,7 @@ function GroupItem(props: { group: Group; className?: string }) { return ( <Row className={clsx('items-center justify-between gap-2 p-2', className)}> <Row className="line-clamp-1 items-center gap-2"> - <GroupLink group={group} /> + <GroupLinkItem group={group} /> </Row> <JoinOrLeaveGroupButton group={group} /> </Row> @@ -133,7 +135,7 @@ export function JoinOrLeaveGroupButton(props: { return ( <button className={clsx( - 'btn btn-outline btn-sm', + 'btn btn-outline btn-xs', small && smallStyle, className )} diff --git a/web/components/info-box.tsx b/web/components/info-box.tsx new file mode 100644 index 00000000..34f65089 --- /dev/null +++ b/web/components/info-box.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx' +import { InformationCircleIcon } from '@heroicons/react/solid' + +import { Linkify } from './linkify' + +export function InfoBox(props: { + title: string + text: string + className?: string +}) { + const { title, text, className } = props + return ( + <div className={clsx('rounded-md bg-gray-50 p-4', className)}> + <div className="flex"> + <div className="flex-shrink-0"> + <InformationCircleIcon + className="h-5 w-5 text-gray-400" + aria-hidden="true" + /> + </div> + <div className="ml-3"> + <h3 className="text-sm font-medium text-black">{title}</h3> + <div className="mt-2 text-sm text-black"> + <Linkify text={text} /> + </div> + </div> + </div> + </div> + ) +} diff --git a/web/components/layout/modal.tsx b/web/components/layout/modal.tsx index 7a320f24..af2b66de 100644 --- a/web/components/layout/modal.tsx +++ b/web/components/layout/modal.tsx @@ -7,9 +7,17 @@ export function Modal(props: { children: ReactNode open: boolean setOpen: (open: boolean) => void + size?: 'sm' | 'md' | 'lg' | 'xl' className?: string }) { - const { children, open, setOpen, className } = props + const { children, open, setOpen, size = 'md', className } = props + + const sizeClass = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-2xl', + xl: 'max-w-5xl', + }[size] return ( <Transition.Root show={open} as={Fragment}> @@ -49,7 +57,8 @@ export function Modal(props: { > <div className={clsx( - 'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle', + 'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:align-middle', + sizeClass, className )} > diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 93647a5e..8c9f4e6b 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { LimitBet } from 'common/bet' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { getFormattedMappedValue } from 'common/pseudo-numeric' @@ -8,10 +7,14 @@ import { useState } from 'react' import { useUser, useUserById } from 'web/hooks/use-user' import { cancelBet } from 'web/lib/firebase/api' import { Avatar } from './avatar' +import { Button } from './button' import { Col } from './layout/col' -import { Tabs } from './layout/tabs' +import { Modal } from './layout/modal' +import { Row } from './layout/row' import { LoadingIndicator } from './loading-indicator' import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' +import { Subtitle } from './subtitle' +import { Title } from './title' export function LimitBets(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -28,40 +31,36 @@ export function LimitBets(props: { const yourBets = sortedBets.filter((bet) => bet.userId === user?.id) return ( - <Col - className={clsx( - className, - 'gap-2 overflow-hidden rounded bg-white px-4 py-3' + <Col className={className}> + {yourBets.length === 0 && ( + <OrderBookButton + className="self-end" + limitBets={sortedBets} + contract={contract} + /> + )} + + {yourBets.length > 0 && ( + <Col + className={'mt-4 gap-2 overflow-hidden rounded bg-white px-4 py-3'} + > + <Row className="mt-2 mb-4 items-center justify-between"> + <Subtitle className="!mt-0 !mb-0" text="Your orders" /> + + <OrderBookButton + className="self-end" + limitBets={sortedBets} + contract={contract} + /> + </Row> + + <LimitOrderTable + limitBets={yourBets} + contract={contract} + isYou={true} + /> + </Col> )} - > - <Tabs - tabs={[ - ...(yourBets.length > 0 - ? [ - { - title: 'Your limit orders', - content: ( - <LimitOrderTable - limitBets={yourBets} - contract={contract} - isYou={true} - /> - ), - }, - ] - : []), - { - title: 'All limit orders', - content: ( - <LimitOrderTable - limitBets={sortedBets} - contract={contract} - isYou={false} - /> - ), - }, - ]} - /> </Col> ) } @@ -77,11 +76,13 @@ export function LimitOrderTable(props: { return ( <table className="table-compact table w-full rounded text-gray-500"> <thead> - {!isYou && <th></th>} - <th>Outcome</th> - <th>Amount</th> - <th>{isPseudoNumeric ? 'Value' : 'Prob'}</th> - {isYou && <th></th>} + <tr> + {!isYou && <th></th>} + <th>Outcome</th> + <th>{isPseudoNumeric ? 'Value' : 'Prob'}</th> + <th>Amount</th> + {isYou && <th></th>} + </tr> </thead> <tbody> {limitBets.map((bet) => ( @@ -130,12 +131,12 @@ function LimitBet(props: { )} </div> </td> - <td>{formatMoney(orderAmount - amount)}</td> <td> {isPseudoNumeric ? getFormattedMappedValue(contract)(limitProb) : formatPercent(limitProb)} </td> + <td>{formatMoney(orderAmount - amount)}</td> {isYou && ( <td> {isCancelling ? ( @@ -153,3 +154,53 @@ function LimitBet(props: { </tr> ) } + +export function OrderBookButton(props: { + limitBets: LimitBet[] + contract: CPMMBinaryContract | PseudoNumericContract + className?: string +}) { + const { limitBets, contract, className } = props + const [open, setOpen] = useState(false) + + const yesBets = limitBets.filter((bet) => bet.outcome === 'YES') + const noBets = limitBets.filter((bet) => bet.outcome === 'NO').reverse() + + return ( + <> + <Button + className={className} + onClick={() => setOpen(true)} + size="xs" + color="blue" + > + Order book + </Button> + + <Modal open={open} setOpen={setOpen} size="lg"> + <Col className="rounded bg-white p-4 py-6"> + <Title className="!mt-0" text="Order book" /> + <Row className="hidden items-start justify-start gap-2 md:flex"> + <LimitOrderTable + limitBets={yesBets} + contract={contract} + isYou={false} + /> + <LimitOrderTable + limitBets={noBets} + contract={contract} + isYou={false} + /> + </Row> + <Col className="md:hidden"> + <LimitOrderTable + limitBets={limitBets} + contract={contract} + isYou={false} + /> + </Col> + </Col> + </Modal> + </> + ) +} diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index fec05919..51880f5d 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -3,7 +3,13 @@ import { formatMoney } from 'common/util/format' import { fromNow } from 'web/lib/util/time' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' - +import { Claim, Manalink } from 'common/manalink' +import { useState } from 'react' +import { ShareIconButton } from './share-icon-button' +import { DotsHorizontalIcon } from '@heroicons/react/solid' +import { contractDetailsButtonClassName } from './contract/contract-info-dialog' +import { useUserById } from 'web/hooks/use-user' +import getManalinkUrl from 'web/get-manalink-url' export type ManalinkInfo = { expiresTime: number | null maxUses: number | null @@ -13,98 +19,202 @@ export type ManalinkInfo = { } export function ManalinkCard(props: { - className?: string info: ManalinkInfo - defaultMessage: string - isClaiming: boolean - onClaim?: () => void + className?: string + preview?: boolean }) { - const { className, defaultMessage, isClaiming, info, onClaim } = props + const { className, info, preview = false } = props const { expiresTime, maxUses, uses, amount, message } = info return ( - <div - className={clsx( - className, - 'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all' - )} - > - <Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100"> - <div> - {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} - </div> - <div> - {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} - </div> - </Col> + <Col> + <Col + className={clsx( + className, + 'min-h-20 group rounded-lg bg-gradient-to-br drop-shadow-sm transition-all', + getManalinkGradient(info.amount) + )} + > + <Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100"> + <div> + {maxUses != null + ? `${maxUses - uses}/${maxUses} uses left` + : `Unlimited use`} + </div> + <div> + {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} + </div> + </Col> - <img - className="mb-6 block self-center transition-all group-hover:rotate-12" - src="/logo-white.svg" - width={200} - height={200} - /> - <Row className="justify-end rounded-b-xl bg-white p-4"> - <Col> - <div className="mb-1 text-xl text-indigo-500"> + <img + className={clsx( + 'block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12', + preview ? 'my-2' : 'w-1/2 md:mb-6 md:h-1/2' + )} + src="/logo-white.svg" + /> + <Row className="rounded-b-lg bg-white p-4"> + <div + className={clsx( + 'mb-1 text-xl text-indigo-500', + getManalinkAmountColor(amount) + )} + > {formatMoney(amount)} </div> - <div>{message || defaultMessage}</div> - </Col> - - <div className="ml-auto"> - <button - className={clsx('btn', isClaiming ? 'loading disabled' : '')} - onClick={onClaim} - > - {isClaiming ? '' : 'Claim'} - </button> - </div> - </Row> - </div> + </Row> + </Col> + <div className="text-md mt-2 mb-4 text-gray-500">{message}</div> + </Col> ) } -export function ManalinkCardPreview(props: { +export function ManalinkCardFromView(props: { className?: string - info: ManalinkInfo - defaultMessage: string + link: Manalink + highlightedSlug: string }) { - const { className, defaultMessage, info } = props - const { expiresTime, maxUses, uses, amount, message } = info + const { className, link, highlightedSlug } = props + const { message, amount, expiresTime, maxUses, claims } = link + const [showDetails, setShowDetails] = useState(false) + return ( - <div - className={clsx( - className, - ' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all' - )} - > - <Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100"> - <div> - {maxUses != null - ? `${maxUses - uses}/${maxUses} uses left` - : `Unlimited use`} + <Col> + <Col + className={clsx( + 'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg', + className, + link.slug === highlightedSlug ? 'shadow-md shadow-indigo-400' : '' + )} + > + <Col + className={clsx( + 'relative rounded-t-lg bg-gradient-to-br transition-all', + getManalinkGradient(link.amount) + )} + onClick={() => setShowDetails(!showDetails)} + > + {showDetails && ( + <ClaimsList + className="absolute h-full w-full bg-white opacity-90" + link={link} + /> + )} + <Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100"> + <div> + {maxUses != null + ? `${maxUses - claims.length}/${maxUses} uses left` + : `Unlimited use`} + </div> + <div> + {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} + </div> + </Col> + <img + className={clsx('my-auto block w-1/3 select-none self-center py-3')} + src="/logo-white.svg" + /> + </Col> + <Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg"> + <div + className={clsx( + 'my-auto mb-1 w-full', + getManalinkAmountColor(amount) + )} + > + {formatMoney(amount)} + </div> + <ShareIconButton + toastClassName={'-left-48 min-w-[250%]'} + buttonClassName={'transition-colors'} + onCopyButtonClassName={ + 'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600' + } + copyPayload={getManalinkUrl(link.slug)} + /> + <button + onClick={() => setShowDetails(!showDetails)} + className={clsx( + contractDetailsButtonClassName, + showDetails + ? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600' + : '' + )} + > + <DotsHorizontalIcon className="h-[24px] w-5" /> + </button> + </Row> + </Col> + <div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm"> + {message || ''} + </div> + </Col> + ) +} + +function ClaimsList(props: { link: Manalink; className: string }) { + const { link, className } = props + return ( + <> + <Col className={clsx('px-4 py-2', className)}> + <div className="text-md mb-1 mt-2 w-full font-semibold"> + Claimed by... </div> - <div> - {expiresTime != null - ? `Expires ${fromNow(expiresTime)}` - : 'Never expires'} + <div className="overflow-auto"> + {link.claims.length > 0 ? ( + <> + {link.claims.map((claim) => ( + <Row key={claim.txnId}> + <Claim claim={claim} /> + </Row> + ))} + </> + ) : ( + <div className="h-full"> + No one has claimed this manalink yet! Share your manalink to start + spreading the wealth. + </div> + )} </div> </Col> - - <img - className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12" - src="/logo-white.svg" - /> - <Row className="rounded-b-lg bg-white p-2"> - <Col className="text-md"> - <div className="mb-1 text-indigo-500">{formatMoney(amount)}</div> - <div className="text-xs">{message || defaultMessage}</div> - </Col> - </Row> - </div> + </> ) } + +function Claim(props: { claim: Claim }) { + const { claim } = props + const who = useUserById(claim.toId) + return ( + <Row className="my-1 gap-2 text-xs"> + <div>{who?.name || 'Loading...'}</div> + <div className="text-gray-500">{fromNow(claim.claimedTime)}</div> + </Row> + ) +} + +function getManalinkGradient(amount: number) { + if (amount < 20) { + return 'from-indigo-200 via-indigo-500 to-indigo-800' + } else if (amount >= 20 && amount < 50) { + return 'from-fuchsia-200 via-fuchsia-500 to-fuchsia-800' + } else if (amount >= 50 && amount < 100) { + return 'from-rose-100 via-rose-400 to-rose-700' + } else if (amount >= 100) { + return 'from-amber-200 via-amber-500 to-amber-700' + } +} + +function getManalinkAmountColor(amount: number) { + if (amount < 20) { + return 'text-indigo-500' + } else if (amount >= 20 && amount < 50) { + return 'text-fuchsia-600' + } else if (amount >= 50 && amount < 100) { + return 'text-rose-600' + } else if (amount >= 100) { + return 'text-amber-600' + } +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 12ab8c87..656aff29 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -4,12 +4,12 @@ import { Col } from '../layout/col' import { Row } from '../layout/row' import { Title } from '../title' import { User } from 'common/user' -import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card' +import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' import Textarea from 'react-expanding-textarea' import dayjs from 'dayjs' -import Button from '../button' +import { Button } from '../button' import { getManalinkUrl } from 'web/pages/links' import { DuplicateIcon } from '@heroicons/react/outline' @@ -66,12 +66,14 @@ function CreateManalinkForm(props: { const defaultExpire = 'week' const [expiresIn, setExpiresIn] = useState(defaultExpire) + const defaultMessage = 'from ' + user.name + const [newManalink, setNewManalink] = useState<ManalinkInfo>({ expiresTime: dayjs().add(1, defaultExpire).valueOf(), amount: 100, maxUses: 1, uses: 0, - message: '', + message: defaultMessage, }) const EXPIRE_OPTIONS = { @@ -161,7 +163,8 @@ function CreateManalinkForm(props: { <div className="form-control w-full"> <label className="label">Message</label> <Textarea - placeholder={`From ${user.name}`} + placeholder={defaultMessage} + maxLength={200} className="input input-bordered resize-none" autoFocus value={newManalink.message} @@ -189,11 +192,7 @@ function CreateManalinkForm(props: { {finishedCreating && ( <> <Title className="!my-0" text="Manalink Created!" /> - <ManalinkCardPreview - className="my-4" - defaultMessage={`From ${user.name}`} - info={newManalink} - /> + <ManalinkCard className="my-4" info={newManalink} preview /> <Row className={clsx( 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 979586f9..155a8783 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -11,7 +11,7 @@ import { } from '@heroicons/react/outline' import clsx from 'clsx' import Link from 'next/link' -import { useRouter } from 'next/router' +import Router, { useRouter } from 'next/router' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' @@ -24,13 +24,20 @@ import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' +const logout = async () => { + // log out, and then reload the page, in case SSR wants to boot them out + // of whatever logged-in-only area of the site they might be in + await withTracking(firebaseLogout, 'sign out')() + await Router.replace(Router.asPath) +} + function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, @@ -40,6 +47,8 @@ function getNavigation() { icon: NotificationsIcon, }, + { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, + ...(IS_PRIVATE_MANIFOLD ? [ { @@ -65,7 +74,6 @@ function getMoreNavigation(user?: User | null) { if (!user) { return [ - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, @@ -74,15 +82,15 @@ function getMoreNavigation(user?: User | null) { } return [ - { name: 'Send M$', href: '/links' }, - { name: 'Leaderboards', href: '/leaderboards' }, + { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { name: 'Sign out', href: '#', - onClick: withTracking(firebaseLogout, 'sign out'), + onClick: logout, }, ] } @@ -90,7 +98,6 @@ function getMoreNavigation(user?: User | null) { const signedOutNavigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, - { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'About', href: 'https://docs.manifold.markets/$how-to', @@ -110,6 +117,7 @@ const signedOutMobileNavigation = [ ] const signedInMobileNavigation = [ + { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, ...(IS_PRIVATE_MANIFOLD ? [] : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), @@ -125,15 +133,15 @@ function getMoreMobileNav() { ...(IS_PRIVATE_MANIFOLD ? [] : [ - { name: 'Send M$', href: '/links' }, + { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, ]), - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Sign out', href: '#', - onClick: withTracking(firebaseLogout, 'sign out'), + onClick: logout, }, ] } @@ -205,15 +213,22 @@ export default function Sidebar(props: { className?: string }) { const user = useUser() const privateUser = usePrivateUser(user?.id) + // usePing(user?.id) + const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user ? signedOutMobileNavigation : signedInMobileNavigation + const memberItems = ( - useMemberGroups(user?.id, { withChatEnabled: true }) ?? [] + useMemberGroups( + user?.id, + { withChatEnabled: true }, + { by: 'mostRecentChatActivityTime' } + ) ?? [] ).map((group: Group) => ({ name: group.name, - href: groupPath(group.slug), + href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, })) return ( @@ -242,7 +257,10 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> )} - + {/* Spacer if there are any groups */} + {memberItems.length > 0 && ( + <hr className="!my-4 mr-2 border-gray-300" /> + )} {privateUser && ( <GroupsList currentPage={router.asPath} @@ -265,11 +283,7 @@ export default function Sidebar(props: { className?: string }) { )} {/* Spacer if there are any groups */} - {memberItems.length > 0 && ( - <div className="py-3"> - <div className="h-[1px] bg-gray-300" /> - </div> - )} + {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />} {privateUser && ( <GroupsList currentPage={router.asPath} @@ -298,8 +312,18 @@ function GroupsList(props: { // Set notification as seen if our current page is equal to the isSeenOnHref property useEffect(() => { + const currentPageWithoutQuery = currentPage.split('?')[0] + const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2] preferredNotifications.forEach((notification) => { - if (notification.isSeenOnHref === currentPage) { + if ( + notification.isSeenOnHref === currentPage || + // Old chat style group chat notif was just /group/slug + (notification.isSeenOnHref && + currentPageWithoutQuery.includes(notification.isSeenOnHref)) || + // They're on the home page, so if they've a chat notif, they're seeing the chat + (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && + currentPageWithoutQuery.endsWith(currentPageGroupSlug)) + ) { setNotificationsAsSeen([notification]) } }) diff --git a/web/components/number-input.tsx b/web/components/number-input.tsx index d7159fab..0b48df6e 100644 --- a/web/components/number-input.tsx +++ b/web/components/number-input.tsx @@ -9,8 +9,8 @@ export function NumberInput(props: { numberString: string onChange: (newNumberString: string) => void error: string | undefined - label: string disabled?: boolean + placeholder?: string className?: string inputClassName?: string // Needed to focus the amount input @@ -21,8 +21,8 @@ export function NumberInput(props: { numberString, onChange, error, - label, disabled, + placeholder, className, inputClassName, inputRef, @@ -32,16 +32,17 @@ export function NumberInput(props: { return ( <Col className={className}> <label className="input-group"> - <span className="bg-gray-200 text-sm">{label}</span> <input className={clsx( - 'input input-bordered max-w-[200px] text-lg', + 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', error && 'input-error', inputClassName )} ref={inputRef} type="number" - placeholder="0" + pattern="[0-9]*" + inputMode="numeric" + placeholder={placeholder ?? '0'} maxLength={9} value={numberString} disabled={disabled} diff --git a/web/components/online-user-list.tsx b/web/components/online-user-list.tsx new file mode 100644 index 00000000..d7f52d56 --- /dev/null +++ b/web/components/online-user-list.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx' +import { Avatar } from './avatar' +import { Col } from './layout/col' +import { Row } from './layout/row' +import { UserLink } from './user-page' +import { User } from 'common/user' +import { UserCircleIcon } from '@heroicons/react/solid' +import { useUsers } from 'web/hooks/use-users' +import { partition } from 'lodash' +import { useWindowSize } from 'web/hooks/use-window-size' +import { useState } from 'react' + +const isOnline = (user?: User) => + user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000 + +export function OnlineUserList(props: { users: User[] }) { + let { users } = props + const liveUsers = useUsers().filter((user) => + users.map((u) => u.id).includes(user.id) + ) + if (liveUsers) users = liveUsers + const [onlineUsers, offlineUsers] = partition(users, (user) => isOnline(user)) + const { width, height } = useWindowSize() + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) + // Subtract bottom bar when it's showing (less than lg screen) + const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 + const remainingHeight = + (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight + return ( + <Col + className="mt-4 flex-1 gap-1 hover:overflow-auto" + ref={setContainerRef} + style={{ height: remainingHeight }} + > + {onlineUsers + .concat( + offlineUsers.sort( + (a, b) => (b.lastPingTime ?? 0) - (a.lastPingTime ?? 0) + ) + ) + .slice(0, 15) + .map((user) => ( + <Row + key={user.id} + className={clsx('items-center justify-between gap-2 p-2')} + > + <OnlineUserAvatar key={user.id} user={user} size={'sm'} /> + </Row> + ))} + </Col> + ) +} + +export function OnlineUserAvatar(props: { + user?: User + className?: string + size?: 'sm' | 'xs' | number +}) { + const { user, className, size } = props + + return ( + <Row className={clsx('relative items-center gap-2', className)}> + <Avatar + username={user?.username} + avatarUrl={user?.avatarUrl} + size={size} + className={!isOnline(user) ? 'opacity-50' : ''} + /> + {user && ( + <UserLink + name={user.name} + username={user.username} + className={!isOnline(user) ? 'text-gray-500' : ''} + /> + )} + {isOnline(user) && ( + <div className="absolute left-0 top-0 "> + <UserCircleIcon className="text-primary bg-primary h-3 w-3 rounded-full border-2 border-white" /> + </div> + )} + </Row> + ) +} diff --git a/web/components/page.tsx b/web/components/page.tsx index e76a4dc2..1913eb7a 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -8,9 +8,11 @@ export function Page(props: { rightSidebar?: ReactNode suspend?: boolean className?: string + rightSidebarClassName?: string children?: ReactNode }) { - const { children, rightSidebar, suspend, className } = props + const { children, rightSidebar, suspend, className, rightSidebarClassName } = + props const bottomBarPadding = 'pb-[58px] lg:pb-0 ' return ( @@ -37,7 +39,11 @@ export function Page(props: { <div className="block xl:hidden">{rightSidebar}</div> </main> <aside className="hidden xl:col-span-3 xl:block"> - <div className="sticky top-4 space-y-4">{rightSidebar}</div> + <div + className={clsx('sticky top-4 space-y-4', rightSidebarClassName)} + > + {rightSidebar} + </div> </aside> </div> @@ -56,4 +62,6 @@ const visuallyHiddenStyle = { position: 'absolute', width: 1, whiteSpace: 'nowrap', + userSelect: 'none', + visibility: 'hidden', } as const diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index a585985d..3f4108bc 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,17 +1,34 @@ +import clsx from 'clsx' + export function Pagination(props: { page: number itemsPerPage: number totalItems: number setPage: (page: number) => void scrollToTop?: boolean + className?: string + nextTitle?: string + prevTitle?: string }) { - const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props + const { + page, + itemsPerPage, + totalItems, + setPage, + scrollToTop, + nextTitle, + prevTitle, + className, + } = props const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 return ( <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + className={clsx( + 'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6', + className + )} aria-label="Pagination" > <div className="hidden sm:block"> @@ -25,19 +42,21 @@ export function Pagination(props: { </p> </div> <div className="flex flex-1 justify-between sm:justify-end"> - <a - href={scrollToTop ? '#' : undefined} - className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 0 && setPage(page - 1)} - > - Previous - </a> + {page > 0 && ( + <a + href={scrollToTop ? '#' : undefined} + className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 0 && setPage(page - 1)} + > + {prevTitle ?? 'Previous'} + </a> + )} <a href={scrollToTop ? '#' : undefined} className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" onClick={() => page < maxPage && setPage(page + 1)} > - Next + {nextTitle ?? 'Next'} </a> </div> </nav> diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index 22f1478f..fa50365b 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -19,6 +19,13 @@ export const PortfolioValueSection = memo( return <></> } + // PATCH: If portfolio history started on June 1st, then we label it as "Since June" + // instead of "All time" + const allTimeLabel = + lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z') + ? 'Since June' + : 'All time' + return ( <div> <Row className="gap-8"> @@ -39,7 +46,7 @@ export const PortfolioValueSection = memo( setPortfolioPeriod(e.target.value as Period) }} > - <option value="allTime">All time</option> + <option value="allTime">{allTimeLabel}</option> <option value="weekly">7 days</option> <option value="daily">24 hours</option> </select> diff --git a/web/components/probability-input.tsx b/web/components/probability-input.tsx index 15f73799..cc8b9259 100644 --- a/web/components/probability-input.tsx +++ b/web/components/probability-input.tsx @@ -1,4 +1,7 @@ import clsx from 'clsx' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' +import { getPseudoProbability } from 'common/pseudo-numeric' +import { BucketInput } from './bucket-input' import { Col } from './layout/col' import { Spacer } from './layout/spacer' @@ -6,10 +9,12 @@ export function ProbabilityInput(props: { prob: number | undefined onChange: (newProb: number | undefined) => void disabled?: boolean + placeholder?: string className?: string inputClassName?: string }) { - const { prob, onChange, disabled, className, inputClassName } = props + const { prob, onChange, disabled, placeholder, className, inputClassName } = + props const onProbChange = (str: string) => { let prob = parseInt(str.replace(/\D/g, '')) @@ -27,7 +32,7 @@ export function ProbabilityInput(props: { <label className="input-group"> <input className={clsx( - 'input input-bordered max-w-[200px] text-lg', + 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', inputClassName )} type="number" @@ -35,7 +40,7 @@ export function ProbabilityInput(props: { min={1} pattern="[0-9]*" inputMode="numeric" - placeholder="0" + placeholder={placeholder ?? '0'} maxLength={2} value={prob ?? ''} disabled={disabled} @@ -47,3 +52,43 @@ export function ProbabilityInput(props: { </Col> ) } + +export function ProbabilityOrNumericInput(props: { + contract: CPMMBinaryContract | PseudoNumericContract + prob: number | undefined + setProb: (prob: number | undefined) => void + isSubmitting: boolean + placeholder?: string +}) { + const { contract, prob, setProb, isSubmitting, placeholder } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + + return isPseudoNumeric ? ( + <BucketInput + contract={contract} + onBucketChange={(value) => + setProb( + value === undefined + ? undefined + : 100 * + getPseudoProbability( + value, + contract.min, + contract.max, + contract.isLogScale + ) + ) + } + isSubmitting={isSubmitting} + placeholder={placeholder} + /> + ) : ( + <ProbabilityInput + inputClassName="w-full max-w-none" + prob={prob} + onChange={setProb} + disabled={isSubmitting} + placeholder={placeholder} + /> + ) +} diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx index 507d90c2..4db192a9 100644 --- a/web/components/share-icon-button.tsx +++ b/web/components/share-icon-button.tsx @@ -2,65 +2,48 @@ import React, { useState } from 'react' import { ShareIcon } from '@heroicons/react/outline' import clsx from 'clsx' -import { Contract } from 'common/contract' import { copyToClipboard } from 'web/lib/util/copy' -import { contractPath } from 'web/lib/firebase/contracts' -import { ENV_CONFIG } from 'common/envs/constants' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' -import { Group } from 'common/group' -import { groupPath } from 'web/lib/firebase/groups' - -function copyContractWithReferral(contract: Contract, username?: string) { - const postFix = - username && contract.creatorUsername !== username - ? '?referrer=' + username - : '' - copyToClipboard( - `https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}` - ) -} - -// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically -function copyGroupWithReferral(group: Group, username?: string) { - const postFix = username ? '?referrer=' + username : '' - copyToClipboard( - `https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}` - ) -} export function ShareIconButton(props: { - contract?: Contract - group?: Group buttonClassName?: string + onCopyButtonClassName?: string toastClassName?: string - username?: string children?: React.ReactNode + iconClassName?: string + copyPayload: string }) { const { - contract, buttonClassName, + onCopyButtonClassName, toastClassName, - username, - group, children, + iconClassName, + copyPayload, } = props const [showToast, setShowToast] = useState(false) return ( <div className="relative z-10 flex-shrink-0"> <button - className={clsx(contractDetailsButtonClassName, buttonClassName)} + className={clsx( + contractDetailsButtonClassName, + buttonClassName, + showToast ? onCopyButtonClassName : '' + )} onClick={() => { - if (contract) copyContractWithReferral(contract, username) - if (group) copyGroupWithReferral(group, username) + copyToClipboard(copyPayload) track('copy share link') setShowToast(true) setTimeout(() => setShowToast(false), 2000) }} > - <ShareIcon className="h-[24px] w-5" aria-hidden="true" /> + <ShareIcon + className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} + aria-hidden="true" + /> {children} </button> diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx index a5da585f..be943a34 100644 --- a/web/components/share-market.tsx +++ b/web/components/share-market.tsx @@ -1,5 +1,8 @@ import clsx from 'clsx' -import { Contract, contractUrl } from 'web/lib/firebase/contracts' + +import { ENV_CONFIG } from 'common/envs/constants' + +import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' import { CopyLinkButton } from './copy-link-button' import { Col } from './layout/col' import { Row } from './layout/row' @@ -7,18 +10,15 @@ import { Row } from './layout/row' export function ShareMarket(props: { contract: Contract; className?: string }) { const { contract, className } = props + const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` + return ( <Col className={clsx(className, 'gap-3')}> <div>Share your market</div> <Row className="mb-6 items-center"> - <input - className="input input-bordered flex-1 rounded-r-none text-gray-500" - readOnly - type="text" - value={contractUrl(contract)} - /> <CopyLinkButton - contract={contract} + url={url} + displayUrl={contractUrl(contract)} buttonClassName="btn-md rounded-l-none" toastClassName={'-left-28 mt-1'} /> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index be3f3ac4..09c28920 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,6 +38,8 @@ import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { filterDefined } from 'common/util/array' import { useUserBets } from 'web/hooks/use-user-bets' +import { ReferralsButton } from 'web/components/referrals-button' +import { formatMoney } from 'common/util/format' export function UserLink(props: { name: string @@ -122,6 +124,7 @@ export function UserPage(props: { const yourFollows = useFollows(currentUser?.id) const isFollowing = yourFollows?.includes(user.id) + const profit = user.profitCached.allTime const onFollow = () => { if (!currentUser) return @@ -186,6 +189,17 @@ export function UserPage(props: { <Col className="mx-4 -mt-6"> <span className="text-2xl font-bold">{user.name}</span> <span className="text-gray-500">@{user.username}</span> + <span className="text-gray-500"> + <span + className={clsx( + 'text-md', + profit >= 0 ? 'text-green-600' : 'text-red-400' + )} + > + {formatMoney(profit)} + </span>{' '} + profit + </span> <Spacer h={4} /> @@ -202,7 +216,9 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - {/* <ReferralsButton user={user} currentUser={currentUser} /> */} + {currentUser?.username === 'ian' && ( + <ReferralsButton user={user} currentUser={currentUser} /> + )} <GroupsButton user={user} /> </Row> diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index dda97c0c..3b3cc21d 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -5,6 +5,68 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { resolution } from 'common/contract' +export function YesNoSelector(props: { + selected?: 'YES' | 'NO' + onSelect: (selected: 'YES' | 'NO') => void + className?: string + btnClassName?: string + replaceYesButton?: React.ReactNode + replaceNoButton?: React.ReactNode + isPseudoNumeric?: boolean +}) { + const { + selected, + onSelect, + className, + btnClassName, + replaceNoButton, + replaceYesButton, + isPseudoNumeric, + } = props + + const commonClassNames = + 'inline-flex items-center justify-center rounded-3xl border-2 p-2' + + return ( + <Row className={clsx('space-x-3', className)}> + {replaceYesButton ? ( + replaceYesButton + ) : ( + <button + className={clsx( + commonClassNames, + 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', + selected == 'YES' + ? 'bg-primary text-white' + : 'text-primary bg-transparent', + btnClassName + )} + onClick={() => onSelect('YES')} + > + {isPseudoNumeric ? 'HIGHER' : 'YES'} + </button> + )} + {replaceNoButton ? ( + replaceNoButton + ) : ( + <button + className={clsx( + commonClassNames, + 'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white', + selected == 'NO' + ? 'bg-red-400 text-white' + : 'bg-transparent text-red-400', + btnClassName + )} + onClick={() => onSelect('NO')} + > + {isPseudoNumeric ? 'LOWER' : 'NO'} + </button> + )} + </Row> + ) +} + export function YesNoCancelSelector(props: { selected: resolution | undefined onSelect: (selected: resolution) => void diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index fde50e80..e195936f 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -25,7 +25,7 @@ export const useAlgoFeed = ( getDefaultFeed().then((feed) => setAllFeed(feed)) } else setAllFeed(feed) - trackLatency('feed', getTime()) + trackLatency(user.id, 'feed', getTime()) console.log('"all" feed load time', getTime()) }) diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index e5a074d6..2a8caaea 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -5,7 +5,16 @@ export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState<string[] | undefined>() useEffect(() => { - if (userId) return listenForFollows(userId, setFollowIds) + if (userId) { + const key = `follows:${userId}` + const follows = localStorage.getItem(key) + if (follows) setFollowIds(JSON.parse(follows)) + + return listenForFollows(userId, (follows) => { + setFollowIds(follows) + localStorage.setItem(key, JSON.stringify(follows)) + }) + } }, [userId]) return followIds diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index c3098ba4..84913962 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,13 +2,15 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { - getGroupsWithContractId, listenForGroup, listenForGroups, listenForMemberGroups, + listGroups, } from 'web/lib/firebase/groups' import { getUser, getUsers } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' +import { Contract } from 'common/contract' +import { uniq } from 'lodash' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -32,19 +34,27 @@ export const useGroups = () => { export const useMemberGroups = ( userId: string | null | undefined, - options?: { withChatEnabled: boolean } + options?: { withChatEnabled: boolean }, + sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } ) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { if (userId) - return listenForMemberGroups(userId, (groups) => { - if (options?.withChatEnabled) - return setMemberGroups( - filterDefined(groups.filter((group) => group.chatDisabled !== true)) - ) - return setMemberGroups(groups) - }) - }, [options?.withChatEnabled, userId]) + return listenForMemberGroups( + userId, + (groups) => { + if (options?.withChatEnabled) + return setMemberGroups( + filterDefined( + groups.filter((group) => group.chatDisabled !== true) + ) + ) + return setMemberGroups(groups) + }, + sort + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options?.withChatEnabled, sort?.by, userId]) return memberGroups } @@ -88,19 +98,22 @@ export async function listMembers(group: Group, max?: number) { const { memberIds } = group const numToRetrieve = max ?? memberIds.length if (memberIds.length === 0) return [] - if (numToRetrieve) + if (numToRetrieve > 100) return (await getUsers()).filter((user) => group.memberIds.includes(user.id) ) return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser)) } -export const useGroupsWithContract = (contractId: string | undefined) => { - const [groups, setGroups] = useState<Group[] | null | undefined>() +export const useGroupsWithContract = (contract: Contract) => { + const [groups, setGroups] = useState<Group[]>([]) useEffect(() => { - if (contractId) getGroupsWithContractId(contractId, setGroups) - }, [contractId]) + if (contract.groupSlugs) + listGroups(uniq(contract.groupSlugs)).then((groups) => + setGroups(filterDefined(groups)) + ) + }, [contract.groupSlugs]) return groups } diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index f5502b85..a3ddeb29 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { @@ -6,7 +6,7 @@ import { listenForNotifications, } from 'web/lib/firebase/notifications' import { groupBy, map } from 'lodash' -import { useFirestoreQuery } from '@react-query-firebase/firestore' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' export type NotificationGroup = { @@ -19,36 +19,33 @@ export type NotificationGroup = { // For some reason react-query subscriptions don't actually listen for notifications // Use useUnseenPreferredNotificationGroups to listen for new notifications -export function usePreferredGroupedNotifications(privateUser: PrivateUser) { - const [notificationGroups, setNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const [notifications, setNotifications] = useState<Notification[]>([]) - const key = `notifications-${privateUser.id}-all` +export function usePreferredGroupedNotifications( + privateUser: PrivateUser, + cachedNotifications?: Notification[] +) { + const result = useFirestoreQueryData( + ['notifications-all', privateUser.id], + getNotificationsQuery(privateUser.id) + ) + const notifications = useMemo(() => { + if (result.isLoading) return cachedNotifications ?? [] + if (!result.data) return cachedNotifications ?? [] + const notifications = result.data as Notification[] - const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id)) - useEffect(() => { - if (result.isLoading) return - if (!result.data) return setNotifications([]) - const notifications = result.data.docs.map( - (doc) => doc.data() as Notification - ) - - const notificationsToShow = getAppropriateNotifications( + return getAppropriateNotifications( notifications, privateUser.notificationPreferences ).filter((n) => !n.isSeenOnHref) - setNotifications(notificationsToShow) - }, [privateUser.notificationPreferences, result.data, result.isLoading]) + }, [ + cachedNotifications, + privateUser.notificationPreferences, + result.data, + result.isLoading, + ]) - useEffect(() => { - if (!notifications) return - - const groupedNotifications = groupNotifications(notifications) - setNotificationGroups(groupedNotifications) + return useMemo(() => { + if (notifications) return groupNotifications(notifications) }, [notifications]) - - return notificationGroups } export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { diff --git a/web/hooks/use-ping.ts b/web/hooks/use-ping.ts new file mode 100644 index 00000000..31daa770 --- /dev/null +++ b/web/hooks/use-ping.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import { updateUser } from 'web/lib/firebase/users' + +export const usePing = (userId: string | undefined) => { + useEffect(() => { + if (!userId) return + + const pingInterval = setInterval(() => { + updateUser(userId, { + lastPingTime: Date.now(), + }) + }, 1000 * 30) + + return () => clearInterval(pingInterval) + }, [userId]) +} diff --git a/web/hooks/use-save-referral.ts b/web/hooks/use-save-referral.ts new file mode 100644 index 00000000..788268b0 --- /dev/null +++ b/web/hooks/use-save-referral.ts @@ -0,0 +1,27 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { User, writeReferralInfo } from 'web/lib/firebase/users' + +export const useSaveReferral = ( + user?: User | null, + options?: { + defaultReferrer?: string + contractId?: string + groupId?: string + } +) => { + const router = useRouter() + + useEffect(() => { + const { referrer } = router.query as { + referrer?: string + } + + const actualReferrer = referrer || options?.defaultReferrer + + if (!user && router.isReady && actualReferrer) { + writeReferralInfo(actualReferrer, options?.contractId, options?.groupId) + } + }, [user, router, options]) +} diff --git a/web/hooks/use-seen-contracts.ts b/web/hooks/use-seen-contracts.ts index 501e7b0c..d21ca84c 100644 --- a/web/hooks/use-seen-contracts.ts +++ b/web/hooks/use-seen-contracts.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Contract } from 'common/contract' import { trackView } from 'web/lib/firebase/tracking' import { useIsVisible } from './use-is-visible' +import { useUser } from './use-user' export const useSeenContracts = () => { const [seenContracts, setSeenContracts] = useState<{ @@ -21,18 +22,19 @@ export const useSaveSeenContract = ( contract: Contract ) => { const isVisible = useIsVisible(elem) + const user = useUser() useEffect(() => { - if (isVisible) { + if (isVisible && user) { const newSeenContracts = { ...getSeenContracts(), [contract.id]: Date.now(), } localStorage.setItem(key, JSON.stringify(newSeenContracts)) - trackView(contract.id) + trackView(user.id, contract.id) } - }, [isVisible, contract]) + }, [isVisible, user, contract]) } const key = 'feed-seen-contracts' diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index a2590249..9023dc1a 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { useSearchBox } from 'react-instantsearch-hooks-web' import { track } from 'web/lib/service/analytics' +import { DEFAULT_SORT } from 'web/components/contract-search' const MARKETS_SORT = 'markets_sort' @@ -10,16 +11,11 @@ export type Sort = | 'newest' | 'oldest' | 'most-traded' - | 'most-popular' | '24-hour-vol' | 'close-date' | 'resolve-date' | 'last-updated' - -export function checkAgainstQuery(query: string, corpus: string) { - const queryWords = query.toLowerCase().split(' ') - return queryWords.every((word) => corpus.toLowerCase().includes(word)) -} + | 'score' export function getSavedSort() { // TODO: this obviously doesn't work with SSR, common sense would suggest @@ -36,7 +32,7 @@ export function useInitialQueryAndSort(options?: { shouldLoadFromStorage?: boolean }) { const { defaultSort, shouldLoadFromStorage } = defaults(options, { - defaultSort: 'most-popular', + defaultSort: DEFAULT_SORT, shouldLoadFromStorage: true, }) const router = useRouter() @@ -58,9 +54,12 @@ export function useInitialQueryAndSort(options?: { console.log('ready loading from storage ', sort ?? defaultSort) const localSort = getSavedSort() if (localSort) { - router.query.s = localSort // Use replace to not break navigating back. - router.replace(router, undefined, { shallow: true }) + router.replace( + { query: { ...router.query, s: localSort } }, + undefined, + { shallow: true } + ) } setInitialSort(localSort ?? defaultSort) } else { @@ -84,7 +83,9 @@ export function useUpdateQueryAndSort(props: { const setSort = (sort: Sort | undefined) => { if (sort !== router.query.s) { router.query.s = sort - router.push(router, undefined, { shallow: true }) + router.replace({ query: { ...router.query, s: sort } }, undefined, { + shallow: true, + }) if (shouldLoadFromStorage) { localStorage.setItem(MARKETS_SORT, sort || '') } @@ -102,7 +103,9 @@ export function useUpdateQueryAndSort(props: { } else { delete router.query.q } - router.push(router, undefined, { shallow: true }) + router.replace({ query: router.query }, undefined, { + shallow: true, + }) track('search', { query }) }, 500), [router] diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index e04a69ca..4c492d6c 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' @@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, - listenForLogin, listenForPrivateUser, - listenForUser, User, users, } from 'web/lib/firebase/users' -import { useStateCheckEquality } from './use-state-check-equality' -import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { AuthContext } from 'web/components/auth-context' export const useUser = () => { - const [user, setUser] = useStateCheckEquality<User | null | undefined>( - undefined - ) - - useEffect(() => listenForLogin(setUser), [setUser]) - - useEffect(() => { - if (user) { - identifyUser(user.id) - setUserProperty('username', user.username) - - return listenForUser(user.id, setUser) - } - }, [user, setUser]) - - return user + return useContext(AuthContext) } export const usePrivateUser = (userId?: string) => { diff --git a/web/hooks/use-users.ts b/web/hooks/use-users.ts index 1312444e..659395b8 100644 --- a/web/hooks/use-users.ts +++ b/web/hooks/use-users.ts @@ -1,32 +1,28 @@ import { useState, useEffect } from 'react' import { PrivateUser, User } from 'common/user' -import { - listenForAllUsers, - listenForPrivateUsers, -} from 'web/lib/firebase/users' import { groupBy, sortBy, difference } from 'lodash' import { getContractsOfUserBets } from 'web/lib/firebase/bets' import { useFollows } from './use-follows' import { useUser } from './use-user' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { DocumentData } from 'firebase/firestore' +import { users, privateUsers } from 'web/lib/firebase/users' export const useUsers = () => { - const [users, setUsers] = useState<User[]>([]) - - useEffect(() => { - listenForAllUsers(setUsers) - }, []) - - return users + const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, { + subscribe: true, + includeMetadataChanges: true, + }) + return result.data ?? [] } export const usePrivateUsers = () => { - const [users, setUsers] = useState<PrivateUser[]>([]) - - useEffect(() => { - listenForPrivateUsers(setUsers) - }, []) - - return users + const result = useFirestoreQueryData<DocumentData, PrivateUser[]>( + ['private users'], + privateUsers, + { subscribe: true, includeMetadataChanges: true } + ) + return result.data || [] } export const useDiscoverUsers = (userId: string | null | undefined) => { diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts new file mode 100644 index 00000000..b6daea6e --- /dev/null +++ b/web/lib/firebase/auth.ts @@ -0,0 +1,54 @@ +import { PROJECT_ID } from 'common/envs/constants' +import { setCookie, getCookies } from '../util/cookie' +import { IncomingMessage, ServerResponse } from 'http' + +const TOKEN_KINDS = ['refresh', 'id'] as const +type TokenKind = typeof TOKEN_KINDS[number] + +const getAuthCookieName = (kind: TokenKind) => { + const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') + return `FIREBASE_TOKEN_${suffix}` +} + +const ID_COOKIE_NAME = getAuthCookieName('id') +const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') + +export const getAuthCookies = (request?: IncomingMessage) => { + const data = request != null ? request.headers.cookie ?? '' : document.cookie + const cookies = getCookies(data) + return { + idToken: cookies[ID_COOKIE_NAME] as string | undefined, + refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, + } +} + +export const setAuthCookies = ( + idToken?: string, + refreshToken?: string, + response?: ServerResponse +) => { + // these tokens last an hour + const idMaxAge = idToken != null ? 60 * 60 : 0 + const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [ + ['path', '/'], + ['max-age', idMaxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + // these tokens don't expire + const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0 + const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ + ['path', '/'], + ['max-age', refreshMaxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + if (response != null) { + response.setHeader('Set-Cookie', [idCookie, refreshCookie]) + } else { + document.cookie = idCookie + document.cookie = refreshCookie + } +} + +export const deleteAuthCookies = () => setAuthCookies() diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 63efa53b..14594803 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -1,17 +1,17 @@ import dayjs from 'dayjs' import { - doc, - setDoc, - deleteDoc, - where, collection, - query, - getDocs, - orderBy, + deleteDoc, + doc, getDoc, - updateDoc, + getDocs, limit, + orderBy, + query, + setDoc, startAfter, + updateDoc, + where, } from 'firebase/firestore' import { sortBy, sum } from 'lodash' @@ -129,6 +129,7 @@ export async function listContractsByGroupSlug( ): Promise<Contract[]> { const q = query(contracts, where('groupSlugs', 'array-contains', slug)) const snapshot = await getDocs(q) + console.log(snapshot.docs.map((doc) => doc.data())) return snapshot.docs.map((doc) => doc.data()) } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 6d695b7f..151e7fa1 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -7,7 +7,7 @@ import { where, } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { updateContract } from './contracts' import { coll, @@ -22,7 +22,12 @@ export const groups = coll<Group>('groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings' + subpath?: + | 'edit' + | 'markets' + | 'about' + | typeof GROUP_CHAT_SLUG + | 'leaderboards' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } @@ -39,6 +44,10 @@ export async function listAllGroups() { return getValues<Group>(groups) } +export async function listGroups(groupSlugs: string[]) { + return Promise.all(groupSlugs.map(getGroupBySlug)) +} + export function listenForGroups(setGroups: (groups: Group[]) => void) { return listenForValues(groups, setGroups) } @@ -62,29 +71,38 @@ export function listenForGroup( export function listenForMemberGroups( userId: string, - setGroups: (groups: Group[]) => void + setGroups: (groups: Group[]) => void, + sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } ) { const q = query(groups, where('memberIds', 'array-contains', userId)) - + const sorter = (group: Group) => { + if (sort?.by === 'mostRecentChatActivityTime') { + return group.mostRecentChatActivityTime ?? group.createdTime + } + if (sort?.by === 'mostRecentContractAddedTime') { + return group.mostRecentContractAddedTime ?? group.createdTime + } + return group.mostRecentActivityTime + } return listenForValues<Group>(q, (groups) => { - const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) + const sorted = sortBy(groups, [(group) => -sorter(group)]) setGroups(sorted) }) } -export async function getGroupsWithContractId( +export async function listenForGroupsWithContractId( contractId: string, setGroups: (groups: Group[]) => void ) { const q = query(groups, where('contractIds', 'array-contains', contractId)) - setGroups(await getValues<Group>(q)) + return listenForValues<Group>(q, setGroups) } -export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { +export async function addUserToGroupViaId(groupId: string, userId: string) { // get group to get the member ids - const group = await getGroupBySlug(groupSlug) + const group = await getGroup(groupId) if (!group) { - console.error(`Group not found: ${groupSlug}`) + console.error(`Group not found: ${groupId}`) return } return await joinGroup(group, userId) @@ -106,9 +124,27 @@ export async function leaveGroup(group: Group, userId: string): Promise<void> { return await updateGroup(group, { memberIds: uniq(newMemberIds) }) } -export async function addContractToGroup(group: Group, contract: Contract) { +export async function addContractToGroup( + group: Group, + contract: Contract, + userId: string +) { + if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // already in that group + + const newGroupLinks = [ + ...(contract.groupLinks ?? []), + { + groupId: group.id, + createdTime: Date.now(), + slug: group.slug, + userId, + name: group.name, + } as GroupLink, + ] + await updateContract(contract.id, { - groupSlugs: [...(contract.groupSlugs ?? []), group.slug], + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, }) return await updateGroup(group, { contractIds: uniq([...group.contractIds, contract.id]), @@ -120,8 +156,47 @@ export async function addContractToGroup(group: Group, contract: Contract) { }) } -export async function setContractGroupSlugs(group: Group, contractId: string) { - await updateContract(contractId, { groupSlugs: [group.slug] }) +export async function removeContractFromGroup( + group: Group, + contract: Contract +) { + if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // not in that group + + const newGroupLinks = contract.groupLinks?.filter( + (link) => link.slug !== group.slug + ) + await updateContract(contract.id, { + groupSlugs: + contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [], + groupLinks: newGroupLinks ?? [], + }) + const newContractIds = group.contractIds.filter((id) => id !== contract.id) + return await updateGroup(group, { + contractIds: uniq(newContractIds), + }) + .then(() => group) + .catch((err) => { + console.error('error removing contract from group', err) + return err + }) +} + +export async function setContractGroupLinks( + group: Group, + contractId: string, + userId: string +) { + await updateContract(contractId, { + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ], + }) return await updateGroup(group, { contractIds: uniq([...group.contractIds, contractId]), }) diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts new file mode 100644 index 00000000..47eadb45 --- /dev/null +++ b/web/lib/firebase/server-auth.ts @@ -0,0 +1,93 @@ +import * as admin from 'firebase-admin' +import fetch from 'node-fetch' +import { IncomingMessage, ServerResponse } from 'http' +import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' +import { getAuthCookies, setAuthCookies } from './auth' +import { GetServerSideProps, GetServerSidePropsContext } from 'next' + +const ensureApp = async () => { + // Note: firebase-admin can only be imported from a server context, + // because it relies on Node standard library dependencies. + if (admin.apps.length === 0) { + // never initialize twice + return admin.initializeApp({ projectId: PROJECT_ID }) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return admin.apps[0]! +} + +const requestFirebaseIdToken = async (refreshToken: string) => { + // See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token + const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token') + refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey) + const result = await fetch(refreshUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + if (!result.ok) { + throw new Error(`Could not refresh ID token: ${await result.text()}`) + } + return (await result.json()) as any +} + +type RequestContext = { + req: IncomingMessage + res: ServerResponse +} + +export const getServerAuthenticatedUid = async (ctx: RequestContext) => { + const app = await ensureApp() + const auth = app.auth() + const { idToken, refreshToken } = getAuthCookies(ctx.req) + + // If we have a valid ID token, verify the user immediately with no network trips. + // If the ID token doesn't verify, we'll have to refresh it to see who they are. + // If they don't have any tokens, then we have no idea who they are. + if (idToken != null) { + try { + return (await auth.verifyIdToken(idToken))?.uid + } catch { + // plausibly expired; try the refresh token, if it's present + } + } + if (refreshToken != null) { + try { + const resp = await requestFirebaseIdToken(refreshToken) + setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) + return (await auth.verifyIdToken(resp.id_token))?.uid + } catch (e) { + // this is a big unexpected problem -- either their cookies are corrupt + // or the refresh token API is down. functionally, they are not logged in + console.error(e) + } + } + return undefined +} + +export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { + return async (ctx: GetServerSidePropsContext) => { + const uid = await getServerAuthenticatedUid(ctx) + if (uid == null) { + return fn != null ? await fn(ctx) : { props: {} } + } else { + return { redirect: { destination: dest, permanent: false } } + } + } +} + +export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => { + return async (ctx: GetServerSidePropsContext) => { + const uid = await getServerAuthenticatedUid(ctx) + if (uid == null) { + return { redirect: { destination: dest, permanent: false } } + } else { + return fn != null ? await fn(ctx) : { props: {} } + } + } +} diff --git a/web/lib/firebase/storage.ts b/web/lib/firebase/storage.ts index 2fc2ccc7..4918a99c 100644 --- a/web/lib/firebase/storage.ts +++ b/web/lib/firebase/storage.ts @@ -1,4 +1,5 @@ import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage' +import { nanoid } from 'nanoid' import { storage } from './init' // TODO: compress large images @@ -7,7 +8,10 @@ export const uploadImage = async ( file: File, onProgress?: (progress: number, isRunning: boolean) => void ) => { - const storageRef = ref(storage, `user-images/${username}/${file.name}`) + // Replace filename with a nanoid to avoid collisions + const [, ext] = file.name.split('.') + const filename = `${nanoid(10)}.${ext}` + const storageRef = ref(storage, `user-images/${username}/${filename}`) const uploadTask = uploadBytesResumable(storageRef, file) let resolvePromise: (url: string) => void diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts index f6ad3aa8..d1828e01 100644 --- a/web/lib/firebase/tracking.ts +++ b/web/lib/firebase/tracking.ts @@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore' import { db } from './init' import { ClickEvent, LatencyEvent, View } from 'common/tracking' -import { listenForLogin, User } from './users' -let user: User | null = null -if (typeof window !== 'undefined') { - listenForLogin((u) => (user = u)) -} - -export async function trackView(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'views')) +export async function trackView(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'views')) const view: View = { contractId, @@ -21,9 +14,8 @@ export async function trackView(contractId: string) { return await setDoc(ref, view) } -export async function trackClick(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'events')) +export async function trackClick(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'events')) const clickEvent: ClickEvent = { type: 'click', @@ -35,11 +27,11 @@ export async function trackClick(contractId: string) { } export async function trackLatency( + userId: string, type: 'feed' | 'portfolio', latency: number ) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'latency')) + const ref = doc(collection(db, 'private-users', userId, 'latency')) const latencyEvent: LatencyEvent = { type, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 29cc9266..4f618586 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -15,15 +15,10 @@ import { } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' -import { - onAuthStateChanged, - GoogleAuthProvider, - signInWithPopup, -} from 'firebase/auth' +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { createUser } from './api' import { coll, getValue, @@ -35,9 +30,8 @@ import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' -import { addUserToGroupViaSlug } from 'web/lib/firebase/groups' +import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { removeUndefinedProps } from 'common/util/object' -import { randomString } from 'common/util/random' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' dayjs.extend(utc) @@ -96,16 +90,15 @@ export function listenForPrivateUser( return listenForValue<PrivateUser>(userRef, setPrivateUser) } -const CACHED_USER_KEY = 'CACHED_USER_KEY' const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' -const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY' +const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY' export function writeReferralInfo( defaultReferrerUsername: string, contractId?: string, referralUsername?: string, - groupSlug?: string + groupId?: string ) { const local = safeLocalStorage() const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) @@ -121,7 +114,7 @@ export function writeReferralInfo( local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) // Always write the most recent explicit group invite query value - if (groupSlug) local?.setItem(CACHED_REFERRAL_GROUP_SLUG_KEY, groupSlug) + if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId) // Write the first contract id that we see. const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY) @@ -129,19 +122,19 @@ export function writeReferralInfo( local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId) } -async function setCachedReferralInfoForUser(user: User | null) { +export async function setCachedReferralInfoForUser(user: User | null) { if (!user || user.referredByUserId) return // if the user wasn't created in the last minute, don't bother const now = dayjs().utc() const userCreatedTime = dayjs(user.createdTime) - if (now.diff(userCreatedTime, 'minute') > 1) return + if (now.diff(userCreatedTime, 'minute') > 5) return const local = safeLocalStorage() const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) const cachedReferralContractId = local?.getItem( CACHED_REFERRAL_CONTRACT_ID_KEY ) - const cachedReferralGroupSlug = local?.getItem(CACHED_REFERRAL_GROUP_SLUG_KEY) + const cachedReferralGroupId = local?.getItem(CACHED_REFERRAL_GROUP_ID_KEY) // get user via username if (cachedReferralUsername) @@ -155,6 +148,9 @@ async function setCachedReferralInfoForUser(user: User | null) { referredByContractId: cachedReferralContractId ? cachedReferralContractId : undefined, + referredByGroupId: cachedReferralGroupId + ? cachedReferralGroupId + : undefined, }) ) .catch((err) => { @@ -165,65 +161,25 @@ async function setCachedReferralInfoForUser(user: User | null) { userId: user.id, referredByUserId: referredByUser.id, referredByContractId: cachedReferralContractId, - referredByGroupSlug: cachedReferralGroupSlug, + referredByGroupId: cachedReferralGroupId, }) }) }) - if (cachedReferralGroupSlug) - addUserToGroupViaSlug(cachedReferralGroupSlug, user.id) + if (cachedReferralGroupId) addUserToGroupViaId(cachedReferralGroupId, user.id) - local?.removeItem(CACHED_REFERRAL_GROUP_SLUG_KEY) + local?.removeItem(CACHED_REFERRAL_GROUP_ID_KEY) local?.removeItem(CACHED_REFERRAL_USERNAME_KEY) local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) } -// used to avoid weird race condition -let createUserPromise: Promise<User> | undefined = undefined - -export function listenForLogin(onUser: (user: User | null) => void) { - const local = safeLocalStorage() - const cachedUser = local?.getItem(CACHED_USER_KEY) - onUser(cachedUser && JSON.parse(cachedUser)) - - return onAuthStateChanged(auth, async (fbUser) => { - if (fbUser) { - let user: User | null = await getUser(fbUser.uid) - - if (!user) { - if (createUserPromise == null) { - const local = safeLocalStorage() - let deviceToken = local?.getItem('device-token') - if (!deviceToken) { - deviceToken = randomString() - local?.setItem('device-token', deviceToken) - } - createUserPromise = createUser({ deviceToken }).then((r) => r as User) - } - user = await createUserPromise - } - - onUser(user) - - // Persist to local storage, to reduce login blink next time. - // Note: Cap on localStorage size is ~5mb - local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) - setCachedReferralInfoForUser(user) - } else { - // User logged out; reset to null - onUser(null) - local?.removeItem(CACHED_USER_KEY) - } - }) -} - export async function firebaseLogin() { const provider = new GoogleAuthProvider() return signInWithPopup(auth, provider) } export async function firebaseLogout() { - auth.signOut() + await auth.signOut() } const storage = getStorage(app) @@ -254,16 +210,6 @@ export async function listAllUsers() { return docs.map((doc) => doc.data()) } -export function listenForAllUsers(setUsers: (users: User[]) => void) { - listenForValues(users, setUsers) -} - -export function listenForPrivateUsers( - setUsers: (users: PrivateUser[]) => void -) { - listenForValues(privateUsers, setUsers) -} - export function getTopTraders(period: Period) { const topTraders = query( users, @@ -271,7 +217,7 @@ export function getTopTraders(period: Period) { limit(20) ) - return getValues(topTraders) + return getValues<User>(topTraders) } export function getTopCreators(period: Period) { @@ -280,7 +226,7 @@ export function getTopCreators(period: Period) { orderBy('creatorVolumeCached.' + period, 'desc'), limit(20) ) - return getValues(topCreators) + return getValues<User>(topCreators) } export async function getTopFollowed() { diff --git a/web/lib/util/cookie.ts b/web/lib/util/cookie.ts new file mode 100644 index 00000000..14999fd4 --- /dev/null +++ b/web/lib/util/cookie.ts @@ -0,0 +1,33 @@ +type CookieOptions = string[][] + +const encodeCookie = (name: string, val: string) => { + return `${name}=${encodeURIComponent(val)}` +} + +const decodeCookie = (cookie: string) => { + const parts = cookie.trim().split('=') + if (parts.length < 2) { + throw new Error(`Invalid cookie contents: ${cookie}`) + } + const rest = parts.slice(1).join('') // there may be more = in the value + return [parts[0], decodeURIComponent(rest)] as const +} + +export const setCookie = (name: string, val: string, opts?: CookieOptions) => { + const parts = [encodeCookie(name, val)] + if (opts != null) { + parts.push(...opts.map((opt) => opt.join('='))) + } + return parts.join('; ') +} + +// Note that this intentionally ignores the case where multiple cookies have +// the same name but different paths. Hopefully we never need to think about it. +export const getCookies = (cookies: string) => { + const data = cookies.trim() + if (!data) { + return {} + } else { + return Object.fromEntries(data.split(';').map(decodeCookie)) + } +} diff --git a/web/next.config.js b/web/next.config.js index 56f643d3..37758952 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -4,6 +4,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api' module.exports = { staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, + optimizeFonts: false, experimental: { externalDir: true, optimizeCss: true, diff --git a/web/package.json b/web/package.json index 7393d4b8..279e381f 100644 --- a/web/package.json +++ b/web/package.json @@ -41,7 +41,7 @@ "gridjs-react": "5.0.2", "lodash": "4.17.21", "nanoid": "^3.3.4", - "next": "12.1.2", + "next": "12.2.2", "node-fetch": "3.2.4", "react": "17.0.2", "react-confetti": "6.0.1", diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 17453770..43dd0ad7 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' -import { keyBy, sortBy, groupBy, sumBy, mapValues } from 'lodash' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' @@ -8,9 +7,7 @@ import { BetPanel } from 'web/components/bet-panel' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' import { ResolutionPanel } from 'web/components/resolution-panel' -import { Title } from 'web/components/title' import { Spacer } from 'web/components/layout/spacer' -import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users' import { Contract, getContractFromSlug, @@ -24,28 +21,26 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments' import Custom404 from '../404' import { AnswersPanel } from 'web/components/answers/answers-panel' import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { Leaderboard } from 'web/components/leaderboard' -import { resolvedPayout } from 'common/calculate' -import { formatMoney } from 'common/util/format' -import { useUserById } from 'web/hooks/use-user' import { ContractTabs } from 'web/components/contract/contract-tabs' import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' import Confetti from 'react-confetti' import { NumericBetPanel } from '../../components/numeric-bet-panel' import { NumericResolutionPanel } from '../../components/numeric-resolution-panel' -import { FeedComment } from 'web/components/feed/feed-comments' -import { FeedBet } from 'web/components/feed/feed-bets' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' -import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' -import { useRouter } from 'next/router' +import { useTipTxns } from 'web/hooks/use-tip-txns' import { useLiquidity } from 'web/hooks/use-liquidity' import { richTextToString } from 'common/util/parse' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { + ContractLeaderboard, + ContractTopTrades, +} from 'web/components/contract/contract-leaderboard' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -157,15 +152,10 @@ export function ContractPageContent( const ogCardProps = getOpenGraphProps(contract) - const router = useRouter() - - useEffect(() => { - const { referrer } = router.query as { - referrer?: string - } - if (!user && router.isReady) - writeReferralInfo(contract.creatorUsername, contract.id, referrer) - }, [user, contract, router]) + useSaveReferral(user, { + defaultReferrer: contract.creatorUsername, + contractId: contract.id, + }) const rightSidebar = hasSidePanel ? ( <Col className="gap-4"> @@ -267,129 +257,6 @@ export function ContractPageContent( ) } -function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props - const [users, setUsers] = useState<User[]>() - - const { userProfits, top5Ids } = useMemo(() => { - // Create a map of userIds to total profits (including sales) - const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) - const betsByUser = groupBy(openBets, 'userId') - - const userProfits = mapValues(betsByUser, (bets) => - sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) - ) - // Find the 5 users with the most profits - const top5Ids = Object.entries(userProfits) - .sort(([_i1, p1], [_i2, p2]) => p2 - p1) - .filter(([, p]) => p > 0) - .slice(0, 5) - .map(([id]) => id) - return { userProfits, top5Ids } - }, [contract, bets]) - - useEffect(() => { - if (top5Ids.length > 0) { - listUsers(top5Ids).then((users) => { - const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) - setUsers(sortedUsers) - }) - } - }, [userProfits, top5Ids]) - - return users && users.length > 0 ? ( - <Leaderboard - title="🏅 Top bettors" - users={users || []} - columns={[ - { - header: 'Total profit', - renderCell: (user) => formatMoney(userProfits[user.id] || 0), - }, - ]} - className="mt-12 max-w-sm" - /> - ) : null -} - -function ContractTopTrades(props: { - contract: Contract - bets: Bet[] - comments: Comment[] - tips: CommentTipMap -}) { - const { contract, bets, comments, tips } = props - const commentsById = keyBy(comments, 'id') - const betsById = keyBy(bets, 'id') - - // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit - // Otherwise, we record the profit at resolution time - const profitById: Record<string, number> = {} - for (const bet of bets) { - if (bet.sale) { - const originalBet = betsById[bet.sale.betId] - const profit = bet.sale.amount - originalBet.amount - profitById[bet.id] = profit - profitById[originalBet.id] = profit - } else { - profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount - } - } - - // Now find the betId with the highest profit - const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = useUserById(betsById[topBetId]?.userId) - - // And also the commentId of the comment with the highest profit - const topCommentId = sortBy( - comments, - (c) => c.betId && -profitById[c.betId] - )[0]?.id - - return ( - <div className="mt-12 max-w-sm"> - {topCommentId && profitById[topCommentId] > 0 && ( - <> - <Title text="💬 Proven correct" className="!mt-0" /> - <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedComment - contract={contract} - comment={commentsById[topCommentId]} - tips={tips[topCommentId]} - betsBySameUser={[betsById[topCommentId]]} - truncate={false} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! - </div> - <Spacer h={16} /> - </> - )} - - {/* If they're the same, only show the comment; otherwise show both */} - {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( - <> - <Title text="💸 Smartest money" className="!mt-0" /> - <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedBet - contract={contract} - bet={betsById[topBetId]} - hideOutcome={false} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! - </div> - </> - )} - </div> - ) -} - const getOpenGraphProps = (contract: Contract) => { const { resolution, diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index d081bc9a..52316eb0 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -5,6 +5,7 @@ import Head from 'next/head' import Script from 'next/script' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' +import { AuthProvider } from 'web/components/auth-context' function firstLine(msg: string) { return msg.replace(/\r?\n.*/s, '') @@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) { /> </Head> - <QueryClientProvider client={queryClient}> - <Component {...pageProps} /> - </QueryClientProvider> + <AuthProvider> + <QueryClientProvider client={queryClient}> + <Component {...pageProps} /> + </QueryClientProvider> + </AuthProvider> </> ) } diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index f1a7ccab..b8cb657c 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -6,16 +6,15 @@ export default function Document() { <Html data-theme="mantic" className="min-h-screen"> <Head> <link rel="icon" href={ENV_CONFIG.faviconPath} /> - - <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" - crossOrigin="true" + crossOrigin="anonymous" /> <link - href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;600;700&display=swap" + href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap" rel="stylesheet" + crossOrigin="anonymous" /> <link rel="stylesheet" @@ -24,7 +23,6 @@ export default function Document() { crossOrigin="anonymous" /> </Head> - <body className="font-readex-pro bg-base-200 min-h-screen"> <Main /> <NextScript /> diff --git a/web/pages/add-funds.tsx b/web/pages/add-funds.tsx index f680d47b..ed25a21a 100644 --- a/web/pages/add-funds.tsx +++ b/web/pages/add-funds.tsx @@ -8,6 +8,9 @@ import { checkoutURL } from 'web/lib/service/stripe' import { Page } from 'web/components/page' import { useTracking } from 'web/hooks/use-tracking' import { trackCallback } from 'web/lib/service/analytics' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') export default function AddFundsPage() { const user = useUser() diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index e709e875..81f23ba9 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -9,6 +9,9 @@ import { useContracts } from 'web/hooks/use-contracts' import { mapKeys } from 'lodash' import { useAdmin } from 'web/hooks/use-admin' import { contractPath } from 'web/lib/firebase/contracts' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') function avatarHtml(avatarUrl: string) { return `<img diff --git a/web/pages/api/v0/user/[username]/index.ts b/web/pages/api/v0/user/[username]/index.ts new file mode 100644 index 00000000..58daffcd --- /dev/null +++ b/web/pages/api/v0/user/[username]/index.ts @@ -0,0 +1,19 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getUserByUsername } from 'web/lib/firebase/users' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { LiteUser, ApiError, toLiteUser } from '../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<LiteUser | ApiError> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { username } = req.query + const user = await getUserByUsername(username as string) + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(toLiteUser(user)) +} diff --git a/web/pages/api/v0/user/by-id/[id].ts b/web/pages/api/v0/user/by-id/[id].ts new file mode 100644 index 00000000..6ed67d1c --- /dev/null +++ b/web/pages/api/v0/user/by-id/[id].ts @@ -0,0 +1,19 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getUser } from 'web/lib/firebase/users' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { LiteUser, ApiError, toLiteUser } from '../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<LiteUser | ApiError> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const user = await getUser(id as string) + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(toLiteUser(user)) +} diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index 2cefa13b..89d2d3a3 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -1,6 +1,9 @@ import { sortBy, sumBy, uniqBy } from 'lodash' import clsx from 'clsx' import React, { useEffect, useRef, useState } from 'react' +import Image from 'next/image' +import Confetti from 'react-confetti' + import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -16,11 +19,10 @@ import { useRouter } from 'next/router' import Custom404 from '../404' import { useCharityTxns } from 'web/hooks/use-charity-txns' import { useWindowSize } from 'web/hooks/use-window-size' -import Confetti from 'react-confetti' import { Donation } from 'web/components/charity/feed-items' -import Image from 'next/image' -import { manaToUSD } from '../../../common/util/format' +import { manaToUSD } from 'common/util/format' import { track } from 'web/lib/service/analytics' +import { SEO } from 'web/components/SEO' export default function CharityPageWrapper() { const router = useRouter() @@ -63,6 +65,7 @@ function CharityPage(props: { charity: Charity }) { /> } > + <SEO title={name} description={description} url="/groups" /> {showConfetti && ( <Confetti width={width ? width : 500} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 46201c3d..80003c81 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -13,13 +13,17 @@ import { CharityCard } from 'web/components/charity/charity-card' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Page } from 'web/components/page' -import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' import { getAllCharityTxns } from 'web/lib/firebase/txns' import { manaToUSD } from 'common/util/format' import { quadraticMatches } from 'common/quadratic-funding' import { Txn } from 'common/txn' import { useTracking } from 'web/hooks/use-tracking' +import { searchInAny } from 'common/util/parse' +import { getUser } from 'web/lib/firebase/users' +import { SiteLink } from 'web/components/site-link' +import { User } from 'common/user' +import { SEO } from 'web/components/SEO' export async function getStaticProps() { const txns = await getAllCharityTxns() @@ -33,6 +37,7 @@ export async function getStaticProps() { ]) const matches = quadraticMatches(txns, totalRaised) const numDonors = uniqBy(txns, (txn) => txn.fromId).length + const mostRecentDonor = await getUser(txns[txns.length - 1].fromId) return { props: { @@ -41,6 +46,7 @@ export async function getStaticProps() { matches, txns, numDonors, + mostRecentDonor, }, revalidate: 60, } @@ -49,22 +55,28 @@ export async function getStaticProps() { type Stat = { name: string stat: string + url?: string } function DonatedStats(props: { stats: Stat[] }) { const { stats } = props return ( <dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3"> - {stats.map((item) => ( + {stats.map((stat) => ( <div - key={item.name} + key={stat.name} className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6" > <dt className="truncate text-sm font-medium text-gray-500"> - {item.name} + {stat.name} </dt> + <dd className="mt-1 text-3xl font-semibold text-gray-900"> - {item.stat} + {stat.url ? ( + <SiteLink href={stat.url}>{stat.stat}</SiteLink> + ) : ( + <span>{stat.stat}</span> + )} </dd> </div> ))} @@ -78,8 +90,9 @@ export default function Charity(props: { matches: { [charityId: string]: number } txns: Txn[] numDonors: number + mostRecentDonor: User }) { - const { totalRaised, charities, matches, numDonors } = props + const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) @@ -88,10 +101,12 @@ export default function Charity(props: { () => charities.filter( (charity) => - charity.name.toLowerCase().includes(query.toLowerCase()) || - charity.preview.toLowerCase().includes(query.toLowerCase()) || - charity.description.toLowerCase().includes(query.toLowerCase()) || - (charity.tags as string[])?.includes(query.toLowerCase()) + searchInAny( + query, + charity.name, + charity.preview, + charity.description + ) || (charity.tags as string[])?.includes(query.toLowerCase()) ), [charities, query] ) @@ -100,10 +115,15 @@ export default function Charity(props: { return ( <Page> + <SEO + title="Manifold for Charity" + description="Donate your prediction market earnings to charity on Manifold." + url="/charity" + /> <Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className=""> <Title className="!mt-0" text="Manifold for Charity" /> - <span className="text-gray-600"> + {/* <span className="text-gray-600"> Through July 15, up to $25k of donations will be matched via{' '} <SiteLink href="https://wtfisqf.com/" className="font-bold"> quadratic funding @@ -113,6 +133,9 @@ export default function Charity(props: { the FTX Future Fund </SiteLink> ! + </span> */} + <span className="text-gray-600"> + Convert your M$ earnings into real charitable donations. </span> <DonatedStats stats={[ @@ -125,8 +148,9 @@ export default function Charity(props: { stat: `${numDonors}`, }, { - name: 'Matched via quadratic funding', - stat: manaToUSD(sum(Object.values(matches))), + name: 'Most recent donor', + stat: mostRecentDonor.name ?? 'Nobody', + url: `/${mostRecentDonor.username}`, }, ]} /> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 3ac11993..2d45e831 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,4 +1,5 @@ import { Answer } from 'common/answer' +import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { useState } from 'react' import { ContractsGrid } from 'web/components/contract/contracts-list' @@ -28,22 +29,14 @@ export default function ContractSearchFirestore(props: { const [sort, setSort] = useState(initialSort || 'newest') const [query, setQuery] = useState(initialQuery) - const queryWords = query.toLowerCase().split(' ') - function check(corpus: string) { - return queryWords.every((word) => corpus.toLowerCase().includes(word)) - } - - let matches = (contracts ?? []).filter( - (c) => - check(c.question) || - check(c.creatorName) || - check(c.creatorUsername) || - check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) || - check( - ((c as any).answers ?? []) - .map((answer: Answer) => answer.text) - .join(' ') - ) + let matches = (contracts ?? []).filter((c) => + searchInAny( + query, + c.question, + c.creatorName, + c.lowercaseTags.map((tag) => `#${tag}`).join(' '), + ((c as any).answers ?? []).map((answer: Answer) => answer.text).join(' ') + ) ) if (sort === 'newest') { @@ -61,10 +54,8 @@ export default function ContractSearchFirestore(props: { ) } else if (sort === 'most-traded') { matches.sort((a, b) => b.volume - a.volume) - } else if (sort === 'most-popular') { - matches.sort( - (a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0) - ) + } else if (sort === 'score') { + matches.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) } else if (sort === '24-hour-vol') { // Use lodash for stable sort, so previous sort breaks all ties. matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days) @@ -111,7 +102,7 @@ export default function ContractSearchFirestore(props: { > <option value="newest">Newest</option> <option value="oldest">Oldest</option> - <option value="most-popular">Most popular</option> + <option value="score">Most popular</option> <option value="most-traded">Most traded</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ebbf2c82..fb87ff4d 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { setContractGroupSlugs, getGroup } from 'web/lib/firebase/groups' +import { getGroup, setContractGroupLinks } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -29,6 +29,11 @@ import { User } from 'common/user' import { TextEditor, useTextEditor } from 'web/components/editor' import { Checkbox } from 'web/components/checkbox' import { ENV_CONFIG } from 'common/envs/constants' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { Title } from 'web/components/title' +import { SEO } from 'web/components/SEO' + +export const getServerSideProps = redirectIfLoggedOut('/') type NewQuestionParams = { groupId?: string @@ -70,8 +75,15 @@ export default function Create() { return ( <Page> + <SEO + title="Create a market" + description="Create a play-money prediction market on any question." + url="/create" + /> <div className="mx-auto w-full max-w-2xl"> <div className="rounded-lg px-6 py-4 sm:py-0"> + <Title className="!mt-0" text="Create a market" /> + <form> <div className="form-control w-full"> <label className="label"> @@ -100,7 +112,7 @@ export default function Create() { // Allow user to create a new contract export function NewContract(props: { - creator: User + creator?: User | null question: string params?: NewQuestionParams }) { @@ -214,7 +226,7 @@ export function NewContract(props: { min, max, initialValue, - isLogScale: (min ?? 0) < 0 ? false : isLogScale, + isLogScale, groupId: selectedGroup?.id, }) ) @@ -225,7 +237,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await setContractGroupSlugs(selectedGroup, result.id) + await setContractGroupLinks(selectedGroup, result.id, creator.id) } await router.push(contractPath(result as Contract)) @@ -301,15 +313,13 @@ export function NewContract(props: { /> </Row> - {!(min !== undefined && min < 0) && ( - <Checkbox - className="my-2 text-sm" - label="Log scale" - checked={isLogScale} - toggle={() => setIsLogScale(!isLogScale)} - disabled={isSubmitting} - /> - )} + <Checkbox + className="my-2 text-sm" + label="Log scale" + checked={isLogScale} + toggle={() => setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> {min !== undefined && max !== undefined && min >= max && ( <div className="mt-2 mb-2 text-sm text-red-500"> @@ -354,7 +364,7 @@ export function NewContract(props: { selectedGroup={selectedGroup} setSelectedGroup={setSelectedGroup} creator={creator} - showSelector={showGroupSelector} + options={{ showSelector: showGroupSelector, showLabel: true }} /> </div> @@ -386,12 +396,10 @@ export function NewContract(props: { type={'date'} className="input input-bordered mt-4" onClick={(e) => e.stopPropagation()} - onChange={(e) => - setCloseDate(dayjs(e.target.value).format('YYYY-MM-DD') || '') - } + onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} disabled={isSubmitting} - value={dayjs(closeDate).format('YYYY-MM-DD')} + value={closeDate} /> <input type={'time'} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a364de43..eebf0619 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,27 +1,23 @@ -import { take, sortBy, debounce } from 'lodash' +import { debounce, sortBy, take } from 'lodash' +import PlusSmIcon from '@heroicons/react/solid/PlusSmIcon' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { - groupPath, - getGroupBySlug, - updateGroup, - joinGroup, addContractToGroup, + getGroupBySlug, + groupPath, + joinGroup, + updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' -import { - firebaseLogin, - getUser, - User, - writeReferralInfo, -} from 'web/lib/firebase/users' +import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup } from 'web/hooks/use-group' +import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' @@ -32,22 +28,15 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -import { - createButtonStyle, - CreateQuestionButton, -} from 'web/components/create-question-button' -import React, { useEffect, useState } from 'react' +import { CreateQuestionButton } from 'web/components/create-question-button' +import React, { useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { - checkAgainstQuery, - getSavedSort, -} from 'web/hooks/use-sort-and-query-params' +import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' -import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' @@ -55,6 +44,11 @@ import { FollowList } from 'web/components/follow-list' import { SearchIcon } from '@heroicons/react/outline' import { useTipTxns } from 'web/hooks/use-tip-txns' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { searchInAny } from 'common/util/parse' +import { useWindowSize } from 'web/hooks/use-window-size' +import { CopyLinkButton } from 'web/components/copy-link-button' +import { ENV_CONFIG } from 'common/envs/constants' +import { useSaveReferral } from 'web/hooks/use-save-referral' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -114,9 +108,9 @@ export async function getStaticPaths() { } const groupSubpages = [ undefined, - 'chat', - 'questions', - 'rankings', + GROUP_CHAT_SLUG, + 'markets', + 'leaderboards', 'about', ] as const @@ -157,13 +151,16 @@ export default function GroupPage(props: { const messages = useCommentsOnGroup(group?.id) const user = useUser() - useEffect(() => { - const { referrer } = router.query as { - referrer?: string - } - if (!user && router.isReady) - writeReferralInfo(creator.username, undefined, referrer, group?.slug) - }, [user, creator, group, router]) + + useSaveReferral(user, { + defaultReferrer: creator.username, + groupId: group?.id, + }) + + const { width } = useWindowSize() + const chatDisabled = !group || group.chatDisabled + const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 + const showChatTab = !chatDisabled && !showChatSidebar if (group === null || !groupSubpages.includes(page) || slugs[2]) { return <Custom404 /> @@ -172,11 +169,6 @@ export default function GroupPage(props: { const isCreator = user && group && user.id === group.creatorId const isMember = user && memberIds.includes(user.id) - const rightSidebar = ( - <Col className="mt-6 hidden xl:block"> - <JoinOrCreateButton group={group} user={user} isMember={!!isMember} /> - </Col> - ) const leaderboard = ( <Col> <GroupLeaderboards @@ -202,43 +194,46 @@ export default function GroupPage(props: { </Col> ) + const chatTab = ( + <Col className=""> + {messages ? ( + <GroupChat messages={messages} user={user} group={group} tips={tips} /> + ) : ( + <LoadingIndicator /> + )} + </Col> + ) + + const questionsTab = ( + <ContractSearch + querySortOptions={{ + shouldLoadFromStorage: true, + defaultSort: getSavedSort() ?? 'newest', + defaultFilter: 'open', + }} + additionalFilter={{ groupSlug: group.slug }} + /> + ) + const tabs = [ - ...(group.chatDisabled + ...(!showChatTab ? [] : [ { title: 'Chat', - content: messages ? ( - <GroupChat - messages={messages} - user={user} - group={group} - tips={tips} - /> - ) : ( - <LoadingIndicator /> - ), - href: groupPath(group.slug, 'chat'), + content: chatTab, + href: groupPath(group.slug, GROUP_CHAT_SLUG), }, ]), { - title: 'Questions', - content: ( - <ContractSearch - querySortOptions={{ - shouldLoadFromStorage: true, - defaultSort: getSavedSort() ?? 'newest', - defaultFilter: 'open', - }} - additionalFilter={{ groupSlug: group.slug }} - /> - ), - href: groupPath(group.slug, 'questions'), + title: 'Markets', + content: questionsTab, + href: groupPath(group.slug, 'markets'), }, { - title: 'Rankings', + title: 'Leaderboards', content: leaderboard, - href: groupPath(group.slug, 'rankings'), + href: groupPath(group.slug, 'leaderboards'), }, { title: 'About', @@ -246,22 +241,24 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'about'), }, ] - const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat') + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) + return ( - <Page rightSidebar={rightSidebar} className="!pb-0"> + <Page + rightSidebar={showChatSidebar ? chatTab : undefined} + rightSidebarClassName={showChatSidebar ? '!top-0' : ''} + className={showChatSidebar ? '!max-w-7xl !pb-0' : ''} + > <SEO title={group.name} description={`Created by ${creator.name}. ${group.about}`} url={groupPath(group.slug)} /> - <Col className="px-3"> <Row className={'items-center justify-between gap-4'}> <div className={'sm:mb-1'}> <div - className={ - 'line-clamp-1 my-1 text-lg text-indigo-700 sm:my-3 sm:text-2xl' - } + className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'} > {group.name} </div> @@ -269,19 +266,15 @@ export default function GroupPage(props: { <Linkify text={group.about} /> </div> </div> - <div className="hidden sm:block xl:hidden"> - <JoinOrCreateButton + <div className="mt-2"> + <JoinOrAddQuestionsButtons group={group} user={user} isMember={!!isMember} /> </div> </Row> - <div className="block sm:hidden"> - <JoinOrCreateButton group={group} user={user} isMember={!!isMember} /> - </div> </Col> - <Tabs currentPageForAnalytics={groupPath(group.slug)} className={'mb-0 sm:mb-2'} @@ -292,28 +285,14 @@ export default function GroupPage(props: { ) } -function JoinOrCreateButton(props: { +function JoinOrAddQuestionsButtons(props: { group: Group user: User | null | undefined isMember: boolean }) { const { group, user, isMember } = props return user && isMember ? ( - <Row - className={'-mt-2 justify-between sm:mt-0 sm:flex-col sm:justify-center'} - > - <CreateQuestionButton - user={user} - overrideText={'Add a new question'} - className={'hidden w-48 flex-shrink-0 sm:block'} - query={`?groupId=${group.id}`} - /> - <CreateQuestionButton - user={user} - overrideText={'New question'} - className={'block w-40 flex-shrink-0 sm:hidden'} - query={`?groupId=${group.id}`} - /> + <Row className={'mt-0 justify-end'}> <AddContractButton group={group} user={user} /> </Row> ) : group.anyoneCanJoin ? ( @@ -344,6 +323,11 @@ function GroupOverview(props: { }) } + const postFix = user ? '?referrer=' + user.username : '' + const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( + group.slug + )}${postFix}` + return ( <> <Col className="gap-2 rounded-b bg-white p-2"> @@ -388,22 +372,27 @@ function GroupOverview(props: { </span> )} </Row> + {anyoneCanJoin && user && ( - <Row className={'flex-wrap items-center gap-1'}> - <span className={'text-gray-500'}>Share</span> - <ShareIconButton - group={group} - username={user.username} - buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'} - > - <span className={'mx-2'}> - Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! - </span> - </ShareIconButton> - </Row> + <Col className="my-4 px-2"> + <div className="text-lg">Invite</div> + <div className={'mb-2 text-gray-500'}> + Invite a friend to this group and get M${REFERRAL_AMOUNT} if they + sign up! + </div> + + <CopyLinkButton + url={shareUrl} + tracking="copy group share link" + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + </Col> )} + <Col className={'mt-2'}> - <GroupMemberSearch members={members} /> + <div className="mb-2 text-lg">Members</div> + <GroupMemberSearch members={members} group={group} /> </Col> </Col> </> @@ -426,14 +415,20 @@ function SearchBar(props: { setQuery: (query: string) => void }) { ) } -function GroupMemberSearch(props: { members: User[] }) { +function GroupMemberSearch(props: { members: User[]; group: Group }) { const [query, setQuery] = useState('') - const { members } = props + const { group } = props + let { members } = props + + // Use static members on load, but also listen to member changes: + const listenToMembers = useMembers(group) + if (listenToMembers) { + members = listenToMembers + } // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter( - (m) => - checkAgainstQuery(query, m.name) || checkAgainstQuery(query, m.username) + const matches = sortBy(members, [(member) => member.name]).filter((m) => + searchInAny(query, m.name, m.username) ) const matchLimit = 25 @@ -497,14 +492,14 @@ function GroupLeaderboards(props: { <SortedLeaderboard users={members} scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Bettor rankings" + title="🏅 Top traders" header="Profit" maxToShow={maxToShow} /> <SortedLeaderboard users={members} scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Creator rankings" + title="🏅 Top creators" header="Market volume" maxToShow={maxToShow} /> @@ -513,7 +508,7 @@ function GroupLeaderboards(props: { <> <Leaderboard className="max-w-xl" - title="🏅 Top bettors" + title="🏅 Top traders" users={topTraders} columns={[ { @@ -544,26 +539,49 @@ function GroupLeaderboards(props: { } function AddContractButton(props: { group: Group; user: User }) { - const { group } = props + const { group, user } = props const [open, setOpen] = useState(false) async function addContractToCurrentGroup(contract: Contract) { - await addContractToGroup(group, contract) + await addContractToGroup(group, contract, user.id) setOpen(false) } return ( <> + <div className={'flex justify-center'}> + <button + className={clsx('btn btn-sm btn-outline')} + onClick={() => setOpen(true)} + > + <PlusSmIcon className="h-6 w-6" aria-hidden="true" /> question + </button> + </div> + <Modal open={open} setOpen={setOpen} className={'sm:p-0'}> <Col className={ - 'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8' + 'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white' } > - <div className={'text-lg text-indigo-700'}> - Add a question to your group - </div> - <div className={'overflow-y-scroll p-1'}> + <Col className="p-8 pb-0"> + <div className={'text-xl text-indigo-700'}> + Add a question to your group + </div> + + <Col className="items-center"> + <CreateQuestionButton + user={user} + overrideText={'New question'} + className={'w-48 flex-shrink-0 '} + query={`?groupId=${group.id}`} + /> + + <div className={'mt-2 text-lg text-indigo-700'}>or</div> + </Col> + </Col> + + <div className={'overflow-y-scroll sm:px-8'}> <ContractSearch hideOrderSelector={true} onContractClick={addContractToCurrentGroup} @@ -575,26 +593,6 @@ function AddContractButton(props: { group: Group; user: User }) { </div> </Col> </Modal> - <div className={'flex justify-center'}> - <button - className={clsx( - createButtonStyle, - 'hidden w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:block' - )} - onClick={() => setOpen(true)} - > - Add an old question - </button> - <button - className={clsx( - createButtonStyle, - 'block w-40 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:hidden' - )} - onClick={() => setOpen(true)} - > - Old question - </button> - </div> </> ) } diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 2523b789..521742b2 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -1,4 +1,4 @@ -import { sortBy, debounce } from 'lodash' +import { debounce, sortBy } from 'lodash' import Link from 'next/link' import React, { useEffect, useState } from 'react' import { Group } from 'common/group' @@ -12,12 +12,13 @@ import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' -import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { UserLink } from 'web/components/user-page' +import { searchInAny } from 'common/util/parse' +import { SEO } from 'web/components/SEO' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -71,20 +72,28 @@ export default function Groups(props: { const matches = sortBy(groups, [ (group) => -1 * group.contractIds.length, (group) => -1 * group.memberIds.length, - ]).filter( - (g) => - checkAgainstQuery(query, g.name) || - checkAgainstQuery(query, g.about || '') || - checkAgainstQuery(query, creatorsDict[g.creatorId].username) + ]).filter((g) => + searchInAny( + query, + g.name, + g.about || '', + creatorsDict[g.creatorId].username + ) ) const matchesOrderedByRecentActivity = sortBy(groups, [ - (group) => -1 * group.mostRecentActivityTime, - ]).filter( - (g) => - checkAgainstQuery(query, g.name) || - checkAgainstQuery(query, g.about || '') || - checkAgainstQuery(query, creatorsDict[g.creatorId].username) + (group) => + -1 * + (group.mostRecentChatActivityTime ?? + group.mostRecentContractAddedTime ?? + group.mostRecentActivityTime), + ]).filter((g) => + searchInAny( + query, + g.name, + g.about || '', + creatorsDict[g.creatorId].username + ) ) // Not strictly necessary, but makes the "hold delete" experience less laggy @@ -92,6 +101,11 @@ export default function Groups(props: { return ( <Page> + <SEO + title="Groups" + description="Manifold Groups are communities centered around a collection of prediction markets. Discuss and compete on questions with your friends." + url="/groups" + /> <Col className="items-center"> <Col className="w-full max-w-2xl px-4 sm:px-2"> <Row className="items-center justify-between"> @@ -177,7 +191,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { </Link> <div> <Avatar - className={'absolute top-2 right-2'} + className={'absolute top-2 right-2 z-10'} username={creator?.username} avatarUrl={creator?.avatarUrl} noLink={false} @@ -224,7 +238,7 @@ function GroupMembersList(props: { group: Group }) { ) } -export function GroupLink(props: { group: Group; className?: string }) { +export function GroupLinkItem(props: { group: Group; className?: string }) { const { group, className } = props return ( diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 53a873a1..30b93762 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,29 +1,28 @@ import React, { useEffect, useState } from 'react' -import Router, { useRouter } from 'next/router' +import { useRouter } from 'next/router' import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' import { getSavedSort } from 'web/hooks/use-sort-and-query-params' -import { ContractSearch } from 'web/components/contract-search' +import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search' import { Contract } from 'common/contract' import { ContractPageContent } from './[username]/[contractSlug]' import { getContractFromSlug } from 'web/lib/firebase/contracts' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { useSaveReferral } from 'web/hooks/use-save-referral' + +export const getServerSideProps = redirectIfLoggedOut('/') const Home = () => { - const user = useUser() const [contract, setContract] = useContractPage() const router = useRouter() useTracking('view home') - if (user === null) { - Router.replace('/') - return <></> - } + useSaveReferral() return ( <> @@ -32,7 +31,7 @@ const Home = () => { <ContractSearch querySortOptions={{ shouldLoadFromStorage: true, - defaultSort: getSavedSort() ?? 'most-popular', + defaultSort: getSavedSort() ?? DEFAULT_SORT, }} onContractClick={(c) => { // Show contract without navigating to contract page. diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 904fc014..473189aa 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,14 +1,13 @@ -import React from 'react' -import Router from 'next/router' - import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Page } from 'web/components/page' import { LandingPagePanel } from 'web/components/landing-page-panel' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { SEO } from 'web/components/SEO' -export async function getStaticProps() { +export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => { // These hardcoded markets will be shown in the frontpage for signed-out users: const hotContracts = await getContractsBySlugs([ 'will-max-go-to-prom-with-a-girl', @@ -22,25 +21,21 @@ export async function getStaticProps() { 'will-congress-hold-any-hearings-abo-e21f987033b3', 'will-at-least-10-world-cities-have', ]) + return { props: { hotContracts } } +}) - return { - props: { hotContracts }, - revalidate: 60, // regenerate after a minute - } -} - -const Home = (props: { hotContracts: Contract[] }) => { +export default function Home(props: { hotContracts: Contract[] }) { const { hotContracts } = props - const user = useUser() - - if (user) { - Router.replace('/home') - return <></> - } + useSaveReferral() return ( <Page> + <SEO + title="Manifold Markets" + description="Create a play-money prediction market on any topic you care about + and bet with your friends on what will happen!" + /> <div className="px-4 pt-2 md:mt-0 lg:hidden"> <ManifoldLogo /> </div> @@ -58,5 +53,3 @@ const Home = (props: { hotContracts: Contract[] }) => { </Page> ) } - -export default Home diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index ac7a348f..2188ef73 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -9,82 +9,95 @@ import { User, } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' -import { fromPropz, usePropz } from 'web/hooks/use-propz' import { useEffect, useState } from 'react' -import { LoadingIndicator } from 'web/components/loading-indicator' import { Title } from 'web/components/title' import { Tabs } from 'web/components/layout/tabs' import { useTracking } from 'web/hooks/use-tracking' +import { SEO } from 'web/components/SEO' + +export async function getStaticProps() { + const props = await fetchProps() -export const getStaticProps = fromPropz(getStaticPropz) -export async function getStaticPropz() { - return queryLeaderboardUsers('allTime') -} -const queryLeaderboardUsers = async (period: Period) => { - const [topTraders, topCreators, topFollowed] = await Promise.all([ - getTopTraders(period).catch(() => {}), - getTopCreators(period).catch(() => {}), - getTopFollowed().catch(() => {}), - ]) return { - props: { - topTraders, - topCreators, - topFollowed, - }, + props, revalidate: 60, // regenerate after a minute } } -export default function Leaderboards(props: { +const fetchProps = async () => { + const [allTime, monthly, weekly, daily] = await Promise.all([ + queryLeaderboardUsers('allTime'), + queryLeaderboardUsers('monthly'), + queryLeaderboardUsers('weekly'), + queryLeaderboardUsers('daily'), + ]) + const topFollowed = await getTopFollowed() + + return { + allTime, + monthly, + weekly, + daily, + topFollowed, + } +} + +const queryLeaderboardUsers = async (period: Period) => { + const [topTraders, topCreators] = await Promise.all([ + getTopTraders(period), + getTopCreators(period), + ]) + return { + topTraders, + topCreators, + } +} + +type leaderboard = { topTraders: User[] topCreators: User[] +} + +export default function Leaderboards(_props: { + allTime: leaderboard + monthly: leaderboard + weekly: leaderboard + daily: leaderboard topFollowed: User[] }) { - props = usePropz(props, getStaticPropz) ?? { - topTraders: [], - topCreators: [], - topFollowed: [], - } - const [topTradersState, setTopTraders] = useState(props.topTraders) - const [isLoading, setLoading] = useState(false) - const [period, setPeriod] = useState<Period>('allTime') - + const [props, setProps] = useState<Parameters<typeof Leaderboards>[0]>(_props) useEffect(() => { - setLoading(true) - queryLeaderboardUsers(period).then((res) => { - setTopTraders(res.props.topTraders as User[]) - setLoading(false) - }) - }, [period]) + fetchProps().then((props) => setProps(props)) + }, []) const LeaderboardWithPeriod = (period: Period) => { + const { topTraders, topCreators } = props[period] + return ( <> <Col className="mx-4 items-center gap-10 lg:flex-row"> - {!isLoading ? ( - <> - {period === 'allTime' || - period == 'weekly' || - period === 'daily' ? ( //TODO: show other periods once they're available - <Leaderboard - title="🏅 Top bettors" - users={topTradersState} - columns={[ - { - header: 'Total profit', - renderCell: (user) => - formatMoney(user.profitCached[period]), - }, - ]} - /> - ) : ( - <></> - )} - </> - ) : ( - <LoadingIndicator spinnerClassName={'border-gray-500'} /> - )} + <Leaderboard + title="🏅 Top traders" + users={topTraders} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(user.profitCached[period]), + }, + ]} + /> + + <Leaderboard + title="🏅 Top creators" + users={topCreators} + columns={[ + { + header: 'Total bet', + renderCell: (user) => + formatMoney(user.creatorVolumeCached[period]), + }, + ]} + /> </Col> </> ) @@ -93,23 +106,25 @@ export default function Leaderboards(props: { return ( <Page> + <SEO + title="Leaderboards" + description="Manifold's leaderboards show the top traders and market creators." + url="/leaderboards" + /> <Title text={'Leaderboards'} className={'hidden md:block'} /> <Tabs currentPageForAnalytics={'leaderboards'} - defaultIndex={0} - onClick={(title, index) => { - const period = ['allTime', 'monthly', 'weekly', 'daily'][index] - setPeriod(period as Period) - }} + defaultIndex={1} tabs={[ { title: 'All Time', content: LeaderboardWithPeriod('allTime'), }, - { - title: 'Monthly', - content: LeaderboardWithPeriod('monthly'), - }, + // TODO: Enable this near the end of July! + // { + // title: 'Monthly', + // content: LeaderboardWithPeriod('monthly'), + // }, { title: 'Weekly', content: LeaderboardWithPeriod('weekly'), diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 67e7b695..119fec77 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -6,8 +6,9 @@ import { claimManalink } from 'web/lib/firebase/api' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' -import { useUserById } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' export default function ClaimPage() { const user = useUser() @@ -17,7 +18,6 @@ export default function ClaimPage() { const [claiming, setClaiming] = useState(false) const [error, setError] = useState<string | undefined>(undefined) - const fromUser = useUserById(manalink?.fromId) if (!manalink) { return <></> } @@ -30,29 +30,42 @@ export default function ClaimPage() { description="Send mana to anyone via link!" url="/send" /> - <div className="mx-auto max-w-xl"> - <Title text={`Claim M$${manalink.amount} mana`} /> - <ManalinkCard - defaultMessage={fromUser?.name || 'Enjoy this mana!'} - info={info} - isClaiming={claiming} - onClaim={async () => { - setClaiming(true) - try { - if (user == null) { - await firebaseLogin() - } - await claimManalink({ slug: manalink.slug }) - user && router.push(`/${user.username}?claimed-mana=yes`) - } catch (e) { - console.log(e) - const message = - e && e instanceof Object ? e.toString() : 'An error occurred.' - setError(message) - } - setClaiming(false) - }} - /> + <div className="mx-auto max-w-xl px-2"> + <Row className="items-center justify-between"> + <Title text={`Claim M$${manalink.amount} mana`} /> + <div className="my-auto"> + <Button + onClick={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + setClaiming(false) + return + } + if (user?.id == manalink.fromId) { + throw new Error("You can't claim your own manalink.") + } + await claimManalink({ slug: manalink.slug }) + user && router.push(`/${user.username}?claimed-mana=yes`) + } catch (e) { + console.log(e) + const message = + e && e instanceof Object + ? e.toString() + : 'An error occurred.' + setError(message) + } + setClaiming(false) + }} + disabled={claiming} + size="lg" + > + {user ? 'Claim' : 'Login'} + </Button> + </div> + </Row> + <ManalinkCard info={info} /> {error && ( <section className="my-5 text-red-500"> <p>Failed to claim manalink.</p> diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 76c62978..0f91d70c 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -1,7 +1,4 @@ -import clsx from 'clsx' import { useState } from 'react' -import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' -import { Claim, Manalink } from 'common/manalink' import { formatMoney } from 'common/util/format' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -11,18 +8,24 @@ import { Title } from 'web/components/title' import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' import { useUserManalinks } from 'web/lib/firebase/manalinks' -import { fromNow } from 'web/lib/util/time' import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' +import { ManalinkCardFromView } from 'web/components/manalink-card' +import { Pagination } from 'web/components/pagination' +import { Manalink } from 'common/manalink' dayjs.extend(customParseFormat) +const LINKS_PER_PAGE = 24 +export const getServerSideProps = redirectIfLoggedOut('/') + export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } @@ -65,12 +68,58 @@ export default function LinkPage() { don't yet have a Manifold account. </p> <Subtitle text="Your Manalinks" /> - <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> + <ManalinksDisplay + unclaimedLinks={unclaimedLinks} + highlightedSlug={highlightedSlug} + /> </Col> </Page> ) } +function ManalinksDisplay(props: { + unclaimedLinks: Manalink[] + highlightedSlug: string +}) { + const { unclaimedLinks, highlightedSlug } = props + const [page, setPage] = useState(0) + const start = page * LINKS_PER_PAGE + const end = start + LINKS_PER_PAGE + const displayedLinks = unclaimedLinks.slice(start, end) + + if (unclaimedLinks.length === 0) { + return ( + <p className="text-gray-500"> + You don't have any unclaimed manalinks. Send some more to spread the + wealth! + </p> + ) + } else { + return ( + <> + <Col className="grid w-full gap-4 md:grid-cols-2"> + {displayedLinks.map((link) => ( + <ManalinkCardFromView + key={link.slug + link.createdTime} + link={link} + highlightedSlug={highlightedSlug} + /> + ))} + </Col> + <Pagination + page={page} + itemsPerPage={LINKS_PER_PAGE} + totalItems={unclaimedLinks.length} + setPage={setPage} + className="mt-4 bg-transparent" + scrollToTop + /> + </> + ) + } +} + +// TODO: either utilize this or get rid of it export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -118,127 +167,3 @@ export function ClaimDescription(props: { txn: ManalinkTxn }) { </div> ) } - -function ClaimTableRow(props: { claim: Claim }) { - const { claim } = props - const who = useUserById(claim.toId) - return ( - <tr> - <td className="px-5 py-2">{who?.name || 'Loading...'}</td> - <td className="px-5 py-2">{`${new Date( - claim.claimedTime - ).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td> - </tr> - ) -} - -function LinkDetailsTable(props: { link: Manalink }) { - const { link } = props - return ( - <table className="w-full divide-y divide-gray-300 border border-gray-400"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th className="px-5 py-2">Claimed by</th> - <th className="px-5 py-2">Time</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> - {link.claims.length ? ( - link.claims.map((claim) => <ClaimTableRow claim={claim} />) - ) : ( - <tr> - <td className="px-5 py-2" colSpan={2}> - No claims yet. - </td> - </tr> - )} - </tbody> - </table> - ) -} - -function LinkTableRow(props: { link: Manalink; highlight: boolean }) { - const { link, highlight } = props - const [expanded, setExpanded] = useState(false) - return ( - <> - <LinkSummaryRow - link={link} - highlight={highlight} - expanded={expanded} - onToggle={() => setExpanded((exp) => !exp)} - /> - {expanded && ( - <tr> - <td className="bg-gray-100 p-3" colSpan={5}> - <LinkDetailsTable link={link} /> - </td> - </tr> - )} - </> - ) -} - -function LinkSummaryRow(props: { - link: Manalink - highlight: boolean - expanded: boolean - onToggle: () => void -}) { - const { link, highlight, expanded, onToggle } = props - const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', - highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' - ) - return ( - <tr id={link.slug} key={link.slug} className={className}> - <td className="py-4 pl-5" onClick={onToggle}> - {expanded ? ( - <ChevronUpIcon className="h-5 w-5" /> - ) : ( - <ChevronDownIcon className="h-5 w-5" /> - )} - </td> - - <td className="px-5 py-4 font-medium text-gray-900"> - {formatMoney(link.amount)} - </td> - <td className="px-5 py-4">{getManalinkUrl(link.slug)}</td> - <td className="px-5 py-4">{link.claimedUserIds.length}</td> - <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> - <td className="px-5 py-4"> - {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} - </td> - </tr> - ) -} - -function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { - const { links, highlightedSlug } = props - return links.length == 0 ? ( - <p>You don't currently have any outstanding manalinks.</p> - ) : ( - <div className="overflow-scroll"> - <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th></th> - <th className="px-5 py-3.5">Amount</th> - <th className="px-5 py-3.5">Link</th> - <th className="px-5 py-3.5">Uses</th> - <th className="px-5 py-3.5">Max Uses</th> - <th className="px-5 py-3.5">Expires</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white"> - {links.map((link) => ( - <LinkTableRow - link={link} - highlight={link.slug === highlightedSlug} - /> - ))} - </tbody> - </table> - </div> - ) -} diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index a3e851fc..2d3346c1 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -8,7 +8,7 @@ export default function Markets() { <Page> <SEO title="Explore" - description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets." + description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets." url="/markets" /> <ContractSearch /> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f86c4fef..72754d32 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ import { Tabs } from 'web/components/layout/tabs' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import React, { useEffect, useState } from 'react' +import { usePrivateUser } from 'web/hooks/use-user' +import React, { useEffect, useMemo, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -9,10 +9,13 @@ import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { UserLink } from 'web/components/user-page' -import { notification_subscribe_types, PrivateUser } from 'common/user' -import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' -import { LoadingIndicator } from 'web/components/loading-indicator' +import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USERNAME, + PrivateUser, + User, +} from 'common/user' +import { getUser } from 'web/lib/firebase/users' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -27,8 +30,7 @@ import { NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' -import toast from 'react-hot-toast' +import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' @@ -37,13 +39,40 @@ import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' +import { safeLocalStorage } from 'web/lib/util/local' +import { + getServerAuthenticatedUid, + redirectIfLoggedOut, +} from 'web/lib/firebase/server-auth' +import { SiteLink } from 'web/components/site-link' +import { NotificationSettings } from 'web/components/NotificationSettings' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' +const HIGHLIGHT_CLASS = 'bg-indigo-50' -export default function Notifications() { - const user = useUser() +export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => { + const uid = await getServerAuthenticatedUid(ctx) + if (!uid) { + return { props: { user: null } } + } + const user = await getUser(uid) + return { props: { user } } +}) + +export default function Notifications(props: { user: User }) { + const { user } = props const privateUser = usePrivateUser(user?.id) + const local = safeLocalStorage() + let localNotifications = [] as Notification[] + const localSavedNotificationGroups = local?.getItem('notification-groups') + let localNotificationGroups = [] as NotificationGroup[] + if (localSavedNotificationGroups) { + localNotificationGroups = JSON.parse(localSavedNotificationGroups) + localNotifications = localNotificationGroups + .map((g) => g.notifications) + .flat() + } if (!user) return <Custom404 /> return ( @@ -60,9 +89,16 @@ export default function Notifications() { { title: 'Notifications', content: privateUser ? ( - <NotificationsList privateUser={privateUser} /> + <NotificationsList + privateUser={privateUser} + cachedNotifications={localNotifications} + /> ) : ( - <LoadingIndicator /> + <div className={'min-h-[100vh]'}> + <RenderNotificationGroups + notificationGroups={localNotificationGroups} + /> + </div> ), }, { @@ -81,39 +117,13 @@ export default function Notifications() { ) } -function NotificationsList(props: { privateUser: PrivateUser }) { - const { privateUser } = props - const [page, setPage] = useState(0) - const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) - const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = - useState<NotificationGroup[] | undefined>(undefined) - - useEffect(() => { - if (!allGroupedNotifications) return - const start = page * NOTIFICATIONS_PER_PAGE - const end = start + NOTIFICATIONS_PER_PAGE - const maxNotificationsToShow = allGroupedNotifications.slice(start, end) - const remainingNotification = allGroupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } - setPaginatedGroupedNotifications(maxNotificationsToShow) - }, [allGroupedNotifications, page]) - - if (!paginatedGroupedNotifications || !allGroupedNotifications) - return <LoadingIndicator /> - +function RenderNotificationGroups(props: { + notificationGroups: NotificationGroup[] +}) { + const { notificationGroups } = props return ( - <div className={'min-h-[100vh]'}> - {paginatedGroupedNotifications.length === 0 && ( - <div className={'mt-2'}> - You don't have any notifications. Try changing your settings to see - more. - </div> - )} - - {paginatedGroupedNotifications.map((notification) => + <> + {notificationGroups.map((notification) => notification.type === 'income' ? ( <IncomeNotificationGroupItem notificationGroup={notification} @@ -131,6 +141,52 @@ function NotificationsList(props: { privateUser: PrivateUser }) { /> ) )} + </> + ) +} + +function NotificationsList(props: { + privateUser: PrivateUser + cachedNotifications: Notification[] +}) { + const { privateUser, cachedNotifications } = props + const [page, setPage] = useState(0) + const allGroupedNotifications = usePreferredGroupedNotifications( + privateUser, + cachedNotifications + ) + const paginatedGroupedNotifications = useMemo(() => { + if (!allGroupedNotifications) return + const start = page * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = allGroupedNotifications.slice(start, end) + const remainingNotification = allGroupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + const local = safeLocalStorage() + local?.setItem( + 'notification-groups', + JSON.stringify(allGroupedNotifications) + ) + return maxNotificationsToShow + }, [allGroupedNotifications, page]) + + if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div /> + + return ( + <div className={'min-h-[100vh]'}> + {paginatedGroupedNotifications.length === 0 && ( + <div className={'mt-2'}> + You don't have any notifications. Try changing your settings to see + more. + </div> + )} + + <RenderNotificationGroups + notificationGroups={paginatedGroupedNotifications} + /> {paginatedGroupedNotifications.length > 0 && allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( <Pagination @@ -139,6 +195,8 @@ function NotificationsList(props: { privateUser: PrivateUser }) { totalItems={allGroupedNotifications.length} setPage={setPage} scrollToTop + nextTitle={'Older'} + prevTitle={'Newer'} /> )} </div> @@ -234,7 +292,7 @@ function IncomeNotificationGroupItem(props: { 'relative cursor-pointer bg-white px-2 pt-6 text-sm', className, !expanded ? 'hover:bg-gray-100' : '', - highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' + highlighted && !expanded ? HIGHLIGHT_CLASS : '' )} onClick={onClickHandler} > @@ -333,7 +391,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique bettors` + } unique traders` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you` : `in tips on` @@ -372,10 +430,14 @@ function IncomeNotificationItem(props: { <div className={clsx( 'bg-white px-2 pt-6 text-sm sm:px-4', - highlighted && 'bg-indigo-200 hover:bg-indigo-100' + highlighted && HIGHLIGHT_CLASS )} > - <a href={getSourceUrl(notification)}> + <div className={'relative'}> + <SiteLink + href={getSourceUrl(notification) ?? ''} + className={'absolute left-0 right-0 top-0 bottom-0 z-0'} + /> <Row className={'items-center text-gray-500 sm:justify-start'}> <div className={'line-clamp-2 flex max-w-xl shrink '}> <div className={'inline'}> @@ -401,7 +463,7 @@ function IncomeNotificationItem(props: { </div> </Row> <div className={'mt-4 border-b border-gray-300'} /> - </a> + </div> </div> ) } @@ -441,7 +503,7 @@ function NotificationGroupItem(props: { 'relative cursor-pointer bg-white px-2 pt-6 text-sm', className, !expanded ? 'hover:bg-gray-100' : '', - highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' + highlighted && !expanded ? HIGHLIGHT_CLASS : '' )} onClick={onClickHandler} > @@ -550,6 +612,8 @@ function NotificationItem(props: { setNotificationsAsSeen([notification]) }, [notification]) + const questionNeedsResolution = sourceUpdateType == 'closed' + if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> @@ -565,7 +629,7 @@ function NotificationItem(props: { <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification(notification, true, true)} + getReasonForShowingNotification(notification, true)} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel @@ -585,31 +649,39 @@ function NotificationItem(props: { <div className={clsx( 'bg-white px-2 pt-6 text-sm sm:px-4', - highlighted && 'bg-indigo-200 hover:bg-indigo-100' + highlighted && HIGHLIGHT_CLASS )} > - <a - href={getSourceUrl(notification)} - onClick={() => - track('Notification Clicked', { - type: 'notification item', - sourceType, - sourceUserName, - sourceUserAvatarUrl, - sourceUpdateType, - reasonText, - reason, - sourceUserUsername, - sourceText, - }) - } - > + <div className={'relative cursor-pointer'}> + <SiteLink + href={getSourceUrl(notification) ?? ''} + className={'absolute left-0 right-0 top-0 bottom-0 z-0'} + onClick={() => + track('Notification Clicked', { + type: 'notification item', + sourceType, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reasonText, + reason, + sourceUserUsername, + sourceText, + }) + } + /> <Row className={'items-center text-gray-500 sm:justify-start'}> <Avatar - avatarUrl={sourceUserAvatarUrl} + avatarUrl={ + questionNeedsResolution + ? MANIFOLD_AVATAR_URL + : sourceUserAvatarUrl + } size={'sm'} - className={'mr-2'} - username={sourceUserName} + className={'z-10 mr-2'} + username={ + questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername + } /> <div className={'flex w-full flex-row pl-1 sm:pl-0'}> <div @@ -618,18 +690,17 @@ function NotificationItem(props: { } > <div> - {sourceUpdateType != 'closed' && ( + {!questionNeedsResolution && ( <UserLink name={sourceUserName || ''} username={sourceUserUsername || ''} - className={'mr-1 flex-shrink-0'} + className={'relative mr-1 flex-shrink-0'} justFirstName={true} /> )} {getReasonForShowingNotification( notification, - false, - isChildOfGroup + isChildOfGroup ?? false )} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> @@ -650,7 +721,7 @@ function NotificationItem(props: { </div> <div className={'mt-6 border-b border-gray-300'} /> - </a> + </div> </div> ) } @@ -690,15 +761,17 @@ function QuestionOrGroupLink(props: { </span> ) return ( - <a - className={ - 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2 ' - } + <SiteLink + className={'relative ml-1 font-bold'} href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : (sourceType === 'group' || sourceType === 'tip') && sourceSlug + : // User's added to group or received a tip there + (sourceType === 'group' || sourceType === 'tip') && sourceSlug ? `${groupPath(sourceSlug)}` + : // User referral via group + sourceSlug?.includes('/group/') + ? `${sourceSlug}` : '' } onClick={() => @@ -714,7 +787,7 @@ function QuestionOrGroupLink(props: { } > {sourceContractTitle || sourceTitle} - </a> + </SiteLink> ) } @@ -729,12 +802,16 @@ function getSourceUrl(notification: Notification) { } = notification if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + // User referral via contract: if ( sourceContractCreatorUsername && sourceContractSlug && sourceType === 'user' ) return `/${sourceContractCreatorUsername}/${sourceContractSlug}` + // User referral: + if (sourceType === 'user' && !sourceContractSlug) + return `/${sourceUserUsername}` if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` @@ -769,17 +846,10 @@ function NotificationTextLabel(props: { justSummary?: boolean }) { const { className, notification, justSummary } = props - const { - sourceUpdateType, - sourceType, - sourceText, - sourceContractTitle, - reasonText, - } = notification + const { sourceUpdateType, sourceType, sourceText, reasonText } = notification const defaultText = sourceText ?? reasonText ?? '' if (sourceType === 'contract') { - if (justSummary) return <span>{sourceContractTitle}</span> - if (!sourceText) return <div /> + if (justSummary || !sourceText) return <div /> // Resolved contracts if (sourceType === 'contract' && sourceUpdateType === 'resolved') { { @@ -857,27 +927,27 @@ function NotificationTextLabel(props: { function getReasonForShowingNotification( notification: Notification, - simple?: boolean, - replaceOn?: boolean + justSummary: boolean ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') - reasonText = !simple ? 'replied to you on' : 'replied' + reasonText = justSummary ? 'replied' : 'replied to you on' else if (reason === 'tagged_user') - reasonText = !simple ? 'tagged you on' : 'tagged you' + reasonText = justSummary ? 'tagged you' : 'tagged you on' else if (reason === 'reply_to_users_comment') - reasonText = !simple ? 'replied to you on' : 'replied' - else reasonText = `commented on` + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` break case 'contract': - if (reason === 'you_follow_user') reasonText = 'asked' - else if (sourceUpdateType === 'resolved') reasonText = `resolved` - else if (sourceUpdateType === 'closed') - reasonText = `Please resolve your question` - else reasonText = `updated` + if (reason === 'you_follow_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': if (reason === 'on_users_contract') reasonText = `answered your question ` @@ -904,205 +974,5 @@ function getReasonForShowingNotification( default: reasonText = '' } - return replaceOn ? reasonText.replace(' on', '') : reasonText -} - -// TODO: where should we put referral bonus notifications? -function NotificationSettings() { - const user = useUser() - const [notificationSettings, setNotificationSettings] = - useState<notification_subscribe_types>('all') - const [emailNotificationSettings, setEmailNotificationSettings] = - useState<notification_subscribe_types>('all') - const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) - - useEffect(() => { - if (user) listenForPrivateUser(user.id, setPrivateUser) - }, [user]) - - useEffect(() => { - if (!privateUser) return - if (privateUser.notificationPreferences) { - setNotificationSettings(privateUser.notificationPreferences) - } - if ( - privateUser.unsubscribedFromResolutionEmails && - privateUser.unsubscribedFromCommentEmails && - privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('none') - } else if ( - !privateUser.unsubscribedFromResolutionEmails && - !privateUser.unsubscribedFromCommentEmails && - !privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('all') - } else { - setEmailNotificationSettings('less') - } - }, [privateUser]) - - const loading = 'Changing Notifications Settings' - const success = 'Notification Settings Changed!' - function changeEmailNotifications(newValue: notification_subscribe_types) { - if (!privateUser) return - if (newValue === 'all') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: false, - unsubscribedFromAnswerEmails: false, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'less') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'none') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: true, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - } - - function changeInAppNotificationSettings( - newValue: notification_subscribe_types - ) { - if (!privateUser) return - track('In-App Notification Preferences Changed', { - newPreference: newValue, - oldPreference: privateUser.notificationPreferences, - }) - toast.promise( - updatePrivateUser(privateUser.id, { - notificationPreferences: newValue, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - - useEffect(() => { - if (privateUser && privateUser.notificationPreferences) - setNotificationSettings(privateUser.notificationPreferences) - else setNotificationSettings('all') - }, [privateUser]) - - if (!privateUser) { - return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> - } - - function NotificationSettingLine(props: { - label: string - highlight: boolean - }) { - const { label, highlight } = props - return ( - <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> - {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} - {label} - </Row> - ) - } - - return ( - <div className={'p-2'}> - <div>In App Notifications</div> - <ChoicesToggleGroup - currentChoice={notificationSettings} - choicesMap={{ All: 'all', Less: 'less', None: 'none' }} - setChoice={(choice) => - changeInAppNotificationSettings( - choice as notification_subscribe_types - ) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> - <div className={'mt-4 text-sm'}> - <div> - <div className={''}> - You will receive notifications for: - <NotificationSettingLine - label={"Resolution of questions you've interacted with"} - highlight={notificationSettings !== 'none'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={'Activity on your own questions, comments, & answers'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Activity on questions you're betting on"} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Income & referral bonuses you've received"} - /> - <NotificationSettingLine - label={"Activity on questions you've ever bet or commented on"} - highlight={notificationSettings === 'all'} - /> - </div> - </div> - </div> - <div className={'mt-4'}>Email Notifications</div> - <ChoicesToggleGroup - currentChoice={emailNotificationSettings} - choicesMap={{ All: 'all', Less: 'less', None: 'none' }} - setChoice={(choice) => - changeEmailNotifications(choice as notification_subscribe_types) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> - <div className={'mt-4 text-sm'}> - <div> - You will receive emails for: - <NotificationSettingLine - label={"Resolution of questions you're betting on"} - highlight={emailNotificationSettings !== 'none'} - /> - <NotificationSettingLine - label={'Closure of your questions'} - highlight={emailNotificationSettings !== 'none'} - /> - <NotificationSettingLine - label={'Activity on your questions'} - highlight={emailNotificationSettings === 'all'} - /> - <NotificationSettingLine - label={"Activity on questions you've answered or commented on"} - highlight={emailNotificationSettings === 'all'} - /> - </div> - </div> - </div> - ) + return reasonText } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index b80698ae..541f5de9 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' -import Router from 'next/router' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' @@ -18,6 +17,9 @@ import { updateUser, updatePrivateUser } from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') function EditUserField(props: { user: User @@ -134,8 +136,7 @@ export default function ProfilePage() { }) } - if (user === null) { - Router.replace('/') + if (user == null) { return <></> } diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx new file mode 100644 index 00000000..f50c2e2b --- /dev/null +++ b/web/pages/referrals.tsx @@ -0,0 +1,62 @@ +import { Col } from 'web/components/layout/col' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { Page } from 'web/components/page' +import { useTracking } from 'web/hooks/use-tracking' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { REFERRAL_AMOUNT } from 'common/user' +import { CopyLinkButton } from 'web/components/copy-link-button' +import { ENV_CONFIG } from 'common/envs/constants' +import { InfoBox } from 'web/components/info-box' + +export const getServerSideProps = redirectIfLoggedOut('/') + +export default function ReferralsPage() { + const user = useUser() + + useTracking('view referrals') + + const url = `https://${ENV_CONFIG.domain}?referrer=${user?.username}` + + return ( + <Page> + <SEO + title="Referrals" + description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + sign up!`} + url="/referrals" + /> + + <Col className="items-center"> + <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> + <Title className="!mt-0" text="Referrals" /> + <img + className="mb-6 block -scale-x-100 self-center" + src="/logo-flapping-with-money.gif" + width={200} + height={200} + /> + + <div className={'mb-4'}> + Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + sign up! + </div> + + <CopyLinkButton + url={url} + tracking="copy referral link" + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + + <InfoBox + title="FYI" + className="mt-4 max-w-md" + text="You can also earn the referral bonus from sharing the link to any market or group you've created!" + /> + </Col> + </Col> + </Page> + ) +} diff --git a/web/pages/trades.tsx b/web/pages/trades.tsx index 55a08bc6..a29fb7f0 100644 --- a/web/pages/trades.tsx +++ b/web/pages/trades.tsx @@ -1,17 +1,10 @@ import Router from 'next/router' -import { useEffect } from 'react' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' -import { useUser } from 'web/hooks/use-user' +export const getServerSideProps = redirectIfLoggedOut('/') // Deprecated: redirects to /portfolio. // Eventually, this will be removed. export default function TradesPage() { - const user = useUser() - - useEffect(() => { - if (user === null) Router.replace('/') - else Router.replace('/portfolio') - }) - - return <></> + Router.replace('/portfolio') } diff --git a/web/public/logo-flapping-with-money.gif b/web/public/logo-flapping-with-money.gif new file mode 100644 index 00000000..0ef936a4 Binary files /dev/null and b/web/public/logo-flapping-with-money.gif differ diff --git a/web/public/mtg/app.js b/web/public/mtg/app.js new file mode 100644 index 00000000..fc7711d0 --- /dev/null +++ b/web/public/mtg/app.js @@ -0,0 +1,354 @@ +mode = 'PLAY' +allData = {} +total = 0 +unseenTotal = 0 +probList = [] +nameList = [] +k = 12 +extra = 3 +artDict = {} +totalCorrect = 0 +totalSeen = 0 +wordsLeft = k + extra +imagesLeft = k +maxRounds = 20 +whichGuesser = 'counterspell' +un = false +online = false +firstPrint = false +flag = true +page = 1 + +document.location.search.split('&').forEach((pair) => { + let v = pair.split('=') + if (v[0] === '?whichguesser') { + whichGuesser = v[1] + } else if (v[0] === 'un') { + un = v[1] + } else if (v[0] === 'digital') { + online = v[1] + } else if (v[0] === 'original') { + firstPrint = v[1] + } +}) + +let firstFetch = fetch('jsons/' + whichGuesser + page + '.json') +fetchToResponse(firstFetch) + +function putIntoMapAndFetch(data) { + putIntoMap(data.data) + if (data.has_more) { + page += 1 + window.setTimeout(() => + fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json')) + ) + } else { + for (const [key, value] of Object.entries(allData)) { + nameList.push(key) + probList.push( + value.length + + (probList.length === 0 ? 0 : probList[probList.length - 1]) + ) + unseenTotal = total + } + window.console.log(allData) + window.console.log(total) + window.console.log(probList) + window.console.log(nameList) + if (whichGuesser === 'counterspell') { + document.getElementById('guess-type').innerText = 'Counterspell Guesser' + } else if (whichGuesser === 'burn') { + document.getElementById('guess-type').innerText = 'Match With Hot Singles' + } + setUpNewGame() + } +} + +function getKSamples() { + let usedCounters = new Set() + let currentTotal = unseenTotal + let samples = {} + let i = 0 + while (i < k) { + let rand = Math.floor(Math.random() * currentTotal) + let count = 0 + for (const [key, value] of Object.entries(allData)) { + if (usedCounters.has(key)) { + continue + } else if (count >= rand) { + usedCounters.add(key) + currentTotal -= value.length + unseenTotal-- + let randIndex = Math.floor(Math.random() * value.length) + let arts = allData[key].splice(randIndex, 1) + samples[arts[0].artImg] = [key, arts[0].normalImg] + i++ + break + } else { + count += value.length + } + } + } + for (const key of usedCounters) { + if (allData[key].length === 0) { + delete allData[key] + } + } + let count = 0 + while (count < extra) { + let rand = Math.floor(Math.random() * total) + for (let j = 0; j < nameList.length; j++) { + if (j >= rand) { + if (usedCounters.has(nameList[j])) { + break + } + usedCounters.add(nameList[j]) + count += 1 + break + } + } + } + return [samples, usedCounters] +} + +function fetchToResponse(fetch) { + return fetch + .then((response) => response.json()) + .then((json) => { + putIntoMapAndFetch(json) + }) +} + +function determineIfSkip(card) { + if (!un) { + if (card.set_type === 'funny') { + return true + } + } + if (!online) { + if (card.digital) { + return true + } + } + if (firstPrint) { + if ( + card.reprint === true || + (card.frame_effects && card.frame_effects.includes('showcase')) + ) { + return true + } + } + // reskinned card names show in art crop + if (card.flavor_name) { + return true + } + // don't include racist cards + return card.content_warning +} + +function putIntoMap(data) { + for (let i = 0; i < data.length; i++) { + let card = data[i] + if (determineIfSkip(card)) { + continue + } + let name = card.name + // remove slashes from adventure cards + if (card.card_faces) { + name = card.card_faces[0].name + } + let normalImg = '' + if (card.image_uris.normal) { + normalImg = card.image_uris.normal + } else if (card.image_uris.large) { + normalImg = card.image_uris.large + } else if (card.image_uris.small) { + normalImg = card.image_uris.small + } else { + continue + } + let artImg = '' + if (card.image_uris.art_crop) { + artImg = card.image_uris.art_crop + } else { + continue + } + total += 1 + if (!allData[name]) { + allData[name] = [{ artImg: artImg, normalImg: normalImg }] + } else { + allData[name].push({ artImg: artImg, normalImg: normalImg }) + } + } +} + +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)) + let temp = array[i] + array[i] = array[j] + array[j] = temp + } +} + +function setUpNewGame() { + wordsLeft = k + extra + imagesLeft = k + let currentRound = totalSeen / k + if (currentRound + 1 === maxRounds) { + document.getElementById('round-number').innerText = 'Final Round' + } else { + document.getElementById('round-number').innerText = + 'Round ' + (1 + currentRound) + } + + setWordsLeft() + // select new cards + let sampledData = getKSamples() + artDict = sampledData[0] + let randomImages = Object.keys(artDict) + shuffleArray(randomImages) + let namesList = Array.from(sampledData[1]).sort() + // fill in the new cards and names + for (let cardIndex = 1; cardIndex <= k; cardIndex++) { + let currCard = document.getElementById('card-' + cardIndex) + currCard.classList.remove('incorrect') + currCard.dataset.name = '' + currCard.dataset.url = randomImages[cardIndex - 1] + currCard.style.backgroundImage = "url('" + currCard.dataset.url + "')" + } + const nameBank = document.querySelector('.names-bank') + for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) { + currName = document.getElementById('name-' + nameIndex) + // window.console.log(currName) + currName.innerText = namesList[nameIndex - 1] + nameBank.appendChild(currName) + } +} + +function checkAnswers() { + let score = k + // show the correct full cards + for (cardIndex = 1; cardIndex <= k; cardIndex++) { + currCard = document.getElementById('card-' + cardIndex) + let incorrect = true + if (currCard.dataset.name) { + let guess = document.getElementById(currCard.dataset.name).innerText + // window.console.log(artDict[currCard.dataset.url][0], guess); + incorrect = artDict[currCard.dataset.url][0] !== guess + // decide if their guess was correct + } + if (incorrect) currCard.classList.add('incorrect') + // tally some kind of score + if (incorrect) score-- + // show the correct card + currCard.style.backgroundImage = + "url('" + artDict[currCard.dataset.url][1] + "')" + } + totalSeen += k + totalCorrect += score + document.getElementById('score-amount').innerText = score + '/' + k + document.getElementById('score-percent').innerText = Math.round( + (totalCorrect * 100) / totalSeen + ) + document.getElementById('score-amount-total').innerText = + totalCorrect + '/' + totalSeen +} + +function toggleMode() { + event.preventDefault() + if (mode === 'PLAY') { + mode = 'ANSWER' + document.querySelector('.play-page').classList.add('answer-page') + window.console.log(totalSeen) + if (totalSeen / k === maxRounds - 1) { + document.getElementById('submit').style.display = 'none' + } else { + document.getElementById('submit').value = 'Next Round' + } + checkAnswers() + } else { + mode = 'PLAY' + document.querySelector('.play-page').classList.remove('answer-page') + document.getElementById('submit').value = 'Submit' + setUpNewGame() + } +} + +function allowDrop(ev, id) { + ev.preventDefault() +} + +function drag(ev) { + ev.dataTransfer.setData('text', ev.target.id) + let nameEl = document.querySelector('.selected') + if (nameEl) nameEl.classList.remove('selected') +} + +function drop(ev, id) { + ev.preventDefault() + var data = ev.dataTransfer.getData('text') + dropOnCard(id, data) +} + +function returnDrop(ev) { + ev.preventDefault() + var data = ev.dataTransfer.getData('text') + returnToNameBank(data) +} + +function returnToNameBank(name) { + document + .querySelector('.names-bank') + .appendChild(document.getElementById(name)) + let prevContainer = document.querySelector('[data-name=' + name + ']') + if (prevContainer) { + prevContainer.dataset.name = '' + wordsLeft += 1 + imagesLeft += 1 + setWordsLeft() + } +} + +function selectName(ev) { + if (ev.target.parentNode.classList.contains('names-bank')) { + let nameEl = document.querySelector('.selected') + if (nameEl) nameEl.classList.remove('selected') + ev.target.classList.add('selected') + } else { + returnToNameBank(ev.target.id) + } +} + +function dropSelected(ev, id) { + ev.preventDefault() + let nameEl = document.querySelector('.selected') + window.console.log('drop selected', nameEl) + if (!nameEl) return + nameEl.classList.remove('selected') + dropOnCard(id, nameEl.id) +} + +function dropOnCard(id, data) { + let target = document.getElementById('card-' + id) + target.appendChild(document.getElementById(data)) + // if this already has a name, remove that name + if (target.dataset.name) { + returnToNameBank(target.dataset.name) + } + // remove name data from a previous card if there is one + let prevContainer = document.querySelector('[data-name=' + data + ']') + if (prevContainer) { + prevContainer.dataset.name = '' + } else { + wordsLeft -= 1 + imagesLeft -= 1 + setWordsLeft() + } + target.dataset.name = data +} + +function setWordsLeft() { + document.getElementById('words-left').innerText = + 'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft +} diff --git a/web/public/mtg/guess.html b/web/public/mtg/guess.html new file mode 100644 index 00000000..882883a7 --- /dev/null +++ b/web/public/mtg/guess.html @@ -0,0 +1,559 @@ +<!DOCTYPE html> +<html> + <head> + <!-- Google Tag Manager --> + <script> + ;(function (w, d, s, l, i) { + w[l] = w[l] || [] + w[l].push({ + 'gtm.start': new Date().getTime(), + event: 'gtm.js', + }) + var f = d.getElementsByTagName(s)[0], + j = d.createElement(s), + dl = l !== 'dataLayer' ? '&l=' + l : '' + j.async = true + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + f.parentNode.insertBefore(j, f) + })(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG') + </script> + <!-- End Google Tag Manager --> + <meta charset="UTF-8" /> + <script type="text/javascript" src="app.js"></script> + <style type="text/css"> + body { + position: relative; + } + + .play-page { + display: flex; + flex-direction: row-reverse; + font-family: Georgia, 'Times New Roman', Times, serif; + } + + h1 { + font-family: Verdana, Geneva, Tahoma, sans-serif; + text-align: center; + } + + form { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-right: 240px; + } + + .cards-container { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: center; + } + + .card { + width: 230px; + height: 208px; + border: 5px solid lightgrey; + margin: 5px; + align-items: flex-end; + box-sizing: border-box; + border-radius: 11px; + position: relative; + display: flex; + justify-content: center; + /*background-size: contain;*/ + background-size: 220px; + background-repeat: no-repeat; + transition: height 1s, background-image 1s, border 0.4s 0.6s; + background-position-y: calc(50% - 18px); + } + + .card:not([data-name^='name'])::after { + content: ''; + height: 34px; + background: white; + width: 100%; + } + + .answer-page .card { + height: 350px; + /*padding-top: 310px;*/ + /*background-size: cover;*/ + overflow: hidden; + border-color: rgb(0, 146, 156); + } + + .answer-page .card.incorrect { + border-color: rgb(216, 27, 96); + } + + .names-bank { + position: fixed; + padding: 10px 10px 40px; + } + + .names-bank .name { + margin: 6px 0; + } + + .answer-page .names-bank .name { + display: none; + } + + .answer-page .names-bank .word-count { + display: none; + } + + .word-count { + text-align: center; + font-style: italic; + color: #444; + } + + .score { + width: 100%; + text-align: center; + background-color: rgb(255, 193, 7); + width: 200px; + font-family: Verdana, Geneva, Tahoma, sans-serif; + opacity: 0; + } + + .names-bank .score { + overflow: hidden; + height: 0; + } + + .answer-page .names-bank .score { + height: auto; + display: block; + opacity: 1; + transition: opacity 1.2s 0.2s; + padding: 20px; + } + + .name { + width: 230px; + min-height: 36px; + border-radius: 2px; + background-color: lightgrey; + padding: 8px 12px 2px; + box-sizing: border-box; + } + + .card .name { + border-radius: 0 0 5px 5px; + } + + #submit { + margin-top: 10px; + padding: 8px 20px; + background-color: cadetblue; + border: none; + border-radius: 3px; + font-size: 1.1em; + color: white; + cursor: pointer; + } + + #submit:hover { + background-color: rgb(0, 146, 156); + } + + #newGame { + padding: 8px 20px; + background-color: lightpink; + border: none; + position: absolute; + top: 5px; + left: 20px; + border-radius: 3px; + font-size: 0.7em; + cursor: pointer; + } + + #newGame:hover { + background-color: coral; + } + + .selected { + background-color: orange; + } + + @media screen and (orientation: landscape) and (max-height: 680px) { + /* CSS applied when the device is in landscape mode*/ + .names-bank { + padding: 0; + top: 0; + max-height: 100vh; + overflow: scroll; + } + + body { + font-size: 20px; + } + + .word-count { + font-size: 14px; + } + + h1 { + margin-right: 240px; + } + } + + @media screen and (orientation: portrait) and (max-width: 1100px) { + body { + font-size: 1.8em; + } + + .play-page { + flex-direction: column; + } + + .names-bank { + flex-direction: row; + display: flex; + flex-wrap: wrap; + /* position: fixed; */ + padding: 10px 10px 40px; + position: sticky; + top: 0; + z-index: 100; + background: white; + } + + .answer-page .names-bank { + min-width: 100%; + justify-content: center; + } + + form { + margin: 0; + } + + .names-bank .name { + margin: 6px; + } + + .names-bank .score { + width: 0; + } + + .answer-page .names-bank .score { + width: auto; + } + + .word-count { + position: absolute; + margin-top: -20px; + } + + .name { + width: 300px; + } + + .card { + width: 300px; + background-size: 300px; + height: 266px; + } + + .answer-page .card { + height: 454px; + } + } + </style> + </head> + <body> + <!-- Google Tag Manager (noscript) --> + <noscript> + <iframe + src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG" + height="0" + width="0" + style="display: none; visibility: hidden" + ></iframe> + </noscript> + <!-- End Google Tag Manager (noscript) --> + + <h1><span id="guess-type"></span>: <span id="round-number"></span></h1> + + <div class="play-page"> + <div + class="names-bank" + ondrop="returnDrop(event)" + ondragover="event.preventDefault()" + > + <div class="score"> + YOUR SCORE + <div>Correct Answers This Round: <span id="score-amount"></span></div> + <div> + Correct Answers In Total: <span id="score-amount-total"></span> + </div> + <div>Overall Percent: <span id="score-percent"></span>%</div> + </div> + <div class="word-count"><span id="words-left"></span></div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-1" + > + Name 1 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-2" + > + Name 2 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-3" + > + Name 3 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-4" + > + Name 4 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-5" + > + Name 5 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-6" + > + Name 6 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-7" + > + Name 7 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-8" + > + Name 8 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-9" + > + Name 9 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-10" + > + Name 10 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-11" + > + Name 11 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-12" + > + Name 12 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-13" + > + Name 13 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-14" + > + Name 14 + </div> + <div + class="name" + draggable="true" + ondragstart="drag(event)" + onClick="selectName(event)" + id="name-15" + > + Name 15 + </div> + </div> + <form onsubmit="toggleMode(event)"> + <div class="cards-container"> + <div + class="card" + ondrop="drop(event,1)" + ondragover="allowDrop(event,1)" + onclick="dropSelected(event, 1)" + id="card-1" + ></div> + <div + class="card" + ondrop="drop(event,2)" + ondragover="allowDrop(event,2)" + onclick="dropSelected(event, 2)" + id="card-2" + ></div> + <div + class="card" + ondrop="drop(event,3)" + ondragover="allowDrop(event,3)" + onclick="dropSelected(event, 3)" + id="card-3" + ></div> + <div + class="card" + ondrop="drop(event,4)" + ondragover="allowDrop(event,4)" + onclick="dropSelected(event, 4)" + id="card-4" + ></div> + <div + class="card" + ondrop="drop(event,5)" + ondragover="allowDrop(event,5)" + onclick="dropSelected(event, 5)" + id="card-5" + ></div> + <div + class="card" + ondrop="drop(event, 6)" + ondragover="allowDrop(event,6)" + onclick="dropSelected(event,6)" + id="card-6" + ></div> + <div + class="card" + ondrop="drop(event,7)" + ondragover="allowDrop(event,7)" + onclick="dropSelected(event, 7)" + id="card-7" + ></div> + <div + class="card" + ondrop="drop(event,8)" + ondragover="allowDrop(event,8)" + onclick="dropSelected(event, 8)" + id="card-8" + ></div> + <div + class="card" + ondrop="drop(event,9)" + ondragover="allowDrop(event,9)" + onclick="dropSelected(event, 9)" + id="card-9" + ></div> + <div + class="card" + ondrop="drop(event,10)" + ondragover="allowDrop(event,10)" + onclick="dropSelected(event, 10)" + id="card-10" + ></div> + <div + class="card" + ondrop="drop(event,11)" + ondragover="allowDrop(event,11)" + onclick="dropSelected(event, 11)" + id="card-11" + ></div> + <div + class="card" + ondrop="drop(event,12)" + ondragover="allowDrop(event,12)" + onclick="dropSelected(event, 12)" + id="card-12" + ></div> + </div> + <input type="submit" id="submit" value="Submit" /> + </form> + </div> + + <div style="position: absolute; top: 0; left: 0; right: 0; color: grey"> + <form method="get" action="index.html"> + <input type="submit" id="newGame" value="New Game" /> + </form> + </div> + <div style="margin: -40px 0 0; height: 60px"> + <a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a> + </div> + + <div + style=" + font-size: 0.9em; + position: absolute; + bottom: 0; + left: 0; + right: 0; + color: grey; + font-style: italic; + " + > + made by + <a + style="color: rgb(0, 146, 156); font-style: italic" + href="https://idamayer.com" + >Ida Mayer</a + > + & + <a + style="color: rgb(0, 146, 156); font-style: italic" + href="mailto:alexlien.alien@gmail.com" + >Alex Lien</a + >, 2022 + </div> + </body> +</html> diff --git a/web/public/mtg/importCards.py b/web/public/mtg/importCards.py new file mode 100644 index 00000000..343cba1a --- /dev/null +++ b/web/public/mtg/importCards.py @@ -0,0 +1,92 @@ +import time +import requests +import json + +# add category name here +allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn'] + + +def generate_initial_query(category): + string_query = 'https://api.scryfall.com/cards/search?q=' + if category == 'counterspell': + string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure' + elif category == 'beast': + string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken' + elif category == 'terror': + string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \ + '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure' + elif category == 'wrath': + string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure' + elif category == 'burn': + string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \ + '%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \ + '.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \ + '+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure' + # add category string query here + string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \ + '<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \ + '%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \ + '+-frame%3Aextendedart+language%3Aenglish&unique=art&page=' + print(string_query) + return string_query + + +def fetch_and_write_all(category, query): + count = 1 + will_repeat = True + while will_repeat: + will_repeat = fetch_and_write(category, query, count) + count += 1 + + +def fetch_and_write(category, query, count): + query += str(count) + response = requests.get(f"{query}").json() + time.sleep(0.1) + with open('jsons/' + category + str(count) + '.json', 'w') as f: + json.dump(to_compact_write_form(response), f) + return response['has_more'] + + +def to_compact_write_form(response): + fieldsToUse = ['has_more'] + fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital', + 'set_type'] + smallJson = dict() + data = [] + # write all fields needed in response + for field in fieldsToUse: + smallJson[field] = response[field] + # write all fields needed in card + for card in response['data']: + write_card = dict() + for field in fieldsInCard: + if field == 'name' and 'card_faces' in card: + write_card['name'] = card['card_faces'][0]['name'] + elif field == 'image_uris': + write_card['image_uris'] = write_image_uris(card['image_uris']) + elif field in card: + write_card[field] = card[field] + data.append(write_card) + smallJson['data'] = data + return smallJson + + +# only write images needed +def write_image_uris(card_image_uris): + image_uris = dict() + if 'normal' in card_image_uris: + image_uris['normal'] = card_image_uris['normal'] + elif 'large' in card_image_uris: + image_uris['normal'] = card_image_uris['large'] + elif 'small' in card_image_uris: + image_uris['normal'] = card_image_uris['small'] + if card_image_uris: + image_uris['art_crop'] = card_image_uris['art_crop'] + return image_uris + + +if __name__ == "__main__": + for category in allCategories: + print(category) + fetch_and_write_all(category, generate_initial_query(category)) diff --git a/web/public/mtg/index.html b/web/public/mtg/index.html new file mode 100644 index 00000000..62849462 --- /dev/null +++ b/web/public/mtg/index.html @@ -0,0 +1,202 @@ +<!DOCTYPE html> +<html> + <head> + <!-- Google Tag Manager --> + <script> + ;(function (w, d, s, l, i) { + w[l] = w[l] || [] + w[l].push({ + 'gtm.start': new Date().getTime(), + event: 'gtm.js', + }) + var f = d.getElementsByTagName(s)[0], + j = d.createElement(s), + dl = l !== 'dataLayer' ? '&l=' + l : '' + j.async = true + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + f.parentNode.insertBefore(j, f) + })(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG') + </script> + <!-- End Google Tag Manager --> + <meta charset="UTF-8" /> + <style type="text/css"> + body { + position: relative; + } + + .play-page { + display: flex; + flex-direction: row-reverse; + font-family: Georgia, 'Times New Roman', Times, serif; + min-height: 200px; + } + + h1, + h3 { + font-family: Verdana, Geneva, Tahoma, sans-serif; + text-align: center; + } + + #submit { + margin-top: 10px; + padding: 8px 20px; + background-color: cadetblue; + border: none; + border-radius: 3px; + font-size: 1.1em; + color: white; + cursor: pointer; + } + + #submit:hover { + background-color: rgb(0, 146, 156); + } + + [type='radio'] { + display: none; + } + + [type='radio'] + label.radio-label { + background: lightgrey; + display: block; + padding: 10px; + border-radius: 4px; + cursor: pointer; + } + + label.radio-label:hover { + background: darkgrey; + } + + [type='radio']:checked + label.radio-label { + background: lightcoral; + } + + .radio-label h3 { + margin: 0; + display: inline-block; + vertical-align: middle; + width: 220px; + } + + .thumbnail { + display: inline-block; + vertical-align: middle; + width: 67px; + height: 48px; + margin-right: 4px; + } + + body { + padding: 70px 0 30px; + } + + #addl-options { + position: absolute; + top: 30px; + right: 30px; + background-color: white; + padding: 10px; + cursor: pointer; + width: 200px; + } + + #addl-options > summary { + list-style: none; + text-align: right; + } + </style> + </head> + <body> + <!-- Google Tag Manager (noscript) --> + <noscript> + <iframe + src="https://www.googletagmanager.com/ns.html?id=GTM-M3MBVGG" + height="0" + width="0" + style="display: none; visibility: hidden" + ></iframe> + </noscript> + <!-- End Google Tag Manager (noscript) --> + <h1>Magic the Guessering</h1> + <div class="play-page" style="justify-content: center"> + <form + method="get" + action="guess.html" + style="display: flex; flex-direction: column; align-items: center" + > + <input + type="radio" + id="counterspell" + name="whichguesser" + value="counterspell" + checked + /> + <label class="radio-label" for="counterspell"> + <img + class="thumbnail" + src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855" + /> + <h3>Counterspell Guesser</h3></label + ><br /> + + <input type="radio" id="burn" name="whichguesser" value="burn" /> + <label class="radio-label" for="burn"> + <img + class="thumbnail" + src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596" + /> + <h3>Match With Hot Singles</h3></label + ><br /> + + <details id="addl-options"> + <summary> + <img + src="https://mythicspoiler.com/images/buttons/ustset.png" + style="width: 32px; vertical-align: top" + /> + Options + </summary> + <input type="checkbox" name="digital" id="digital" checked /> + <label for="digital">include digital cards</label> + <br /> + <input type="checkbox" name="un" id="un" checked /> + <label for="un">include un-cards</label> + <br /> + <input type="checkbox" name="original" id="original" /> + <label for="original">restrict to only original printing</label> + </details> + <input type="submit" id="submit" value="Play" /> + </form> + </div> + + <div style="margin: -40px 0 0; height: 60px"> + <a href="https://paypal.me/idamayer">Donate, buy us a boba 🧋</a> + </div> + + <div + style=" + font-size: 0.9em; + position: absolute; + bottom: 0; + left: 0; + right: 0; + color: grey; + font-style: italic; + " + > + made by + <a + style="color: rgb(0, 146, 156); font-style: italic" + href="https://idamayer.com" + >Ida Mayer</a + > + & + <a + style="color: rgb(0, 146, 156); font-style: italic" + href="mailto:alexlien.alien@gmail.com" + >Alex Lien</a + >, 2022 + </div> + </body> +</html> diff --git a/web/public/mtg/jsons/burn1.json b/web/public/mtg/jsons/burn1.json new file mode 100644 index 00000000..885fcb4e --- /dev/null +++ b/web/public/mtg/jsons/burn1.json @@ -0,0 +1 @@ +{"has_more": true, "data": [{"name": "Angrath's Fury", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/0/708006ba-d494-4093-b108-8249b110831e.jpg?1555041214", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/0/708006ba-d494-4093-b108-8249b110831e.jpg?1555041214"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Annihilating Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/ae12fd10-c13e-4777-a233-96204ec75ac1.jpg?1562791532", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/ae12fd10-c13e-4777-a233-96204ec75ac1.jpg?1562791532"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Arc Blade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/d/4d1c04fb-213f-4be1-9bba-94c737826bf8.jpg?1562910601", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/d/4d1c04fb-213f-4be1-9bba-94c737826bf8.jpg?1562910601"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Arc Trail", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/4/445e3a0a-29a7-4dc0-80fe-569b9e751db3.jpg?1562816934", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/4/445e3a0a-29a7-4dc0-80fe-569b9e751db3.jpg?1562816934"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Arrow Storm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/5/c57534fb-2591-4003-aeec-6452faa4a759.jpg?1562793262", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/5/c57534fb-2591-4003-aeec-6452faa4a759.jpg?1562793262"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Artillerize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/3/034522ae-f531-44d9-b186-ada046ce0abc.jpg?1562875185", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/034522ae-f531-44d9-b186-ada046ce0abc.jpg?1562875185"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Atarka's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/0/903d78c9-c5b3-45c3-a6d0-7e92b4196ae3.jpg?1562789860", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/0/903d78c9-c5b3-45c3-a6d0-7e92b4196ae3.jpg?1562789860"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Backlash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/a/dadf030d-5451-43fc-bf0c-c1629fdf88ec.jpg?1562938984", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/a/dadf030d-5451-43fc-bf0c-c1629fdf88ec.jpg?1562938984"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Banefire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b188c68a-e9df-4803-a722-1993dd88f833.jpg?1562803150", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b188c68a-e9df-4803-a722-1993dd88f833.jpg?1562803150"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Barbed Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/5/2509482a-68d8-4e94-9d1e-5b069ebdc2e4.jpg?1562635839", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/5/2509482a-68d8-4e94-9d1e-5b069ebdc2e4.jpg?1562635839"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Beacon of Destruction", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c0fae532-7189-450e-aa7f-e639163278fc.jpg?1562879532", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c0fae532-7189-450e-aa7f-e639163278fc.jpg?1562879532"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Blast from the Past", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/c/5ca23782-80d3-4656-afba-f8440c813253.jpg?1562488402", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/c/5ca23782-80d3-4656-afba-f8440c813253.jpg?1562488402"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "funny"}, {"name": "Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/6/26f8c6ab-ae62-4e2e-a5ba-2ec5bbe22445.jpg?1562234516", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/6/26f8c6ab-ae62-4e2e-a5ba-2ec5bbe22445.jpg?1562234516"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/8/a8b6cfd3-4fb1-40a7-a090-de6f8b283cb3.jpg?1562257515", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/8/a8b6cfd3-4fb1-40a7-a090-de6f8b283cb3.jpg?1562257515"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/9/3940d0ca-0ca2-4446-9330-a554c3e89824.jpg?1562908488", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/9/3940d0ca-0ca2-4446-9330-a554c3e89824.jpg?1562908488"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f175c959-3b5d-46a3-9194-fad2359bbff9.jpg?1546740055", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f175c959-3b5d-46a3-9194-fad2359bbff9.jpg?1546740055"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Blazing Salvo", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/7/f7d192ef-a174-4df5-b67f-22918c32cf71.jpg?1562941547", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/7/f7d192ef-a174-4df5-b67f-22918c32cf71.jpg?1562941547"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Blightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/8/68ba5a86-ef90-45fd-bc7a-e870e91a207c.jpg?1592714653", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/8/68ba5a86-ef90-45fd-bc7a-e870e91a207c.jpg?1592714653"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Blightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/c/3c05e8a2-b7d0-4f24-b2ae-8e4db30e5842.jpg?1562702945", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/c/3c05e8a2-b7d0-4f24-b2ae-8e4db30e5842.jpg?1562702945"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Blightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/a/7a1b4c07-588a-444f-9677-3eb1493b5394.jpg?1561757438", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/a/7a1b4c07-588a-444f-9677-3eb1493b5394.jpg?1561757438"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Blur of Blades", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/5/b53f5b9b-d24b-4e9a-bc90-7ed198cd1132.jpg?1562811539", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/5/b53f5b9b-d24b-4e9a-bc90-7ed198cd1132.jpg?1562811539"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bolt of Keranos", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/d/4df70b14-5d67-4a92-aaba-72480c621d10.jpg?1593092169", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/d/4df70b14-5d67-4a92-aaba-72480c621d10.jpg?1593092169"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bonfire of the Damned", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e60610fe-891d-46de-b556-d03b637dccec.jpg?1592709031", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e60610fe-891d-46de-b556-d03b637dccec.jpg?1592709031"}, "reprint": false, "frame_effects": ["miracle"], "digital": false, "set_type": "expansion"}, {"name": "Book Burning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/e/bead678c-7b6a-4668-9919-623312e08a65.jpg?1562631756", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/e/bead678c-7b6a-4668-9919-623312e08a65.jpg?1562631756"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Boros Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/4/d4ddf9cc-40a7-4b4f-bb51-b08171453c9a.jpg?1561848093", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/4/d4ddf9cc-40a7-4b4f-bb51-b08171453c9a.jpg?1561848093"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Boros Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/c/ac8cd7a1-3f79-405b-8930-2206f32c2035.jpg?1622938151", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/c/ac8cd7a1-3f79-405b-8930-2206f32c2035.jpg?1622938151"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Breaking Point", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/6/765ec2c9-8ffe-488a-bebe-e5dd63825a8c.jpg?1562630501", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/6/765ec2c9-8ffe-488a-bebe-e5dd63825a8c.jpg?1562630501"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Breath of Darigaaz", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/8/480bb7e3-df03-454d-ada0-592ef8a4a6f0.jpg?1562909692", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/8/480bb7e3-df03-454d-ada0-592ef8a4a6f0.jpg?1562909692"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Breath of Malfegor", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/a/7a12e4d0-8471-46ac-85e4-a2ea5be8bf8f.jpg?1562642287", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/a/7a12e4d0-8471-46ac-85e4-a2ea5be8bf8f.jpg?1562642287"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Breath of Malfegor", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/b/5b3eb5c5-7ff8-4557-afe7-056ea5f09a49.jpg?1561757216", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/b/5b3eb5c5-7ff8-4557-afe7-056ea5f09a49.jpg?1561757216"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Brimstone Volley", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/9/6960f2da-6b84-4680-8ab2-f0567a5d1b0a.jpg?1562831550", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/9/6960f2da-6b84-4680-8ab2-f0567a5d1b0a.jpg?1562831550"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Browbeat", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/7/77c0eb52-8e09-471a-b00c-aaa1ae244afc.jpg?1592714628", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/7/77c0eb52-8e09-471a-b00c-aaa1ae244afc.jpg?1592714628"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Browbeat", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/4/74f20068-f225-4055-be7a-5c4a18e33b0b.jpg?1562630478", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/4/74f20068-f225-4055-be7a-5c4a18e33b0b.jpg?1562630478"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Browbeat", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/1/1170ee2d-ab25-4c7f-a910-cc01471a2cab.jpg?1562639679", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/1/1170ee2d-ab25-4c7f-a910-cc01471a2cab.jpg?1562639679"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Burn from Within", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f8bdc165-4c6f-47e6-8bda-877c0be3613b.jpg?1576384673", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f8bdc165-4c6f-47e6-8bda-877c0be3613b.jpg?1576384673"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Burning Fields", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/dee12f01-581e-4a3c-a8b5-41bef2516781.jpg?1562257986", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/dee12f01-581e-4a3c-a8b5-41bef2516781.jpg?1562257986"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Burn the Accursed", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/f/ff4d4e6b-564d-46da-8e32-09ed08c8ddc5.jpg?1634350484", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/f/ff4d4e6b-564d-46da-8e32-09ed08c8ddc5.jpg?1634350484"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Burn the Impure", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/5/b5641730-428d-4484-866e-ec1ac669537f.jpg?1562614054", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/5/b5641730-428d-4484-866e-ec1ac669537f.jpg?1562614054"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Burn Trail", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/f/7f01f9a0-f1d0-4241-a270-df4ed673d1fd.jpg?1562832261", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/f/7f01f9a0-f1d0-4241-a270-df4ed673d1fd.jpg?1562832261"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Burst Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/d/2dc16614-5cf8-444d-a5ae-cac25018af68.jpg?1562610949", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/d/2dc16614-5cf8-444d-a5ae-cac25018af68.jpg?1562610949"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Burst Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/b/db539e3e-cefe-4f2c-bc8e-df049426895f.jpg?1561758208", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/b/db539e3e-cefe-4f2c-bc8e-df049426895f.jpg?1561758208"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Cackling Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a54a371e-fb82-41f1-892c-975f932b668e.jpg?1593273099", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a54a371e-fb82-41f1-892c-975f932b668e.jpg?1593273099"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Call In a Professional", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/a/ead68c0a-eed1-4a9c-a790-56f8a79b444c.jpg?1649936108", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/a/ead68c0a-eed1-4a9c-a790-56f8a79b444c.jpg?1649936108"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Carbonize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/4/d4b4767b-edd1-4e36-b363-52114a9afe5e.jpg?1580014480", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/4/d4b4767b-edd1-4e36-b363-52114a9afe5e.jpg?1580014480"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Carbonize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/f/6f565fa1-a1a0-4dd0-b7f4-df65a807d156.jpg?1562530228", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/f/6f565fa1-a1a0-4dd0-b7f4-df65a807d156.jpg?1562530228"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cave-In", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/4/440d9d26-f304-467d-af79-914cc65f082e.jpg?1562380418", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/4/440d9d26-f304-467d-af79-914cc65f082e.jpg?1562380418"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Chain Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/5/b5883762-ca0a-4932-8d2a-41a45796a5f8.jpg?1562860651", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/5/b5883762-ca0a-4932-8d2a-41a45796a5f8.jpg?1562860651"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Chain Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/f/bfb7fe8e-e348-4bf9-aa71-65f0675147e4.jpg?1636769610", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/f/bfb7fe8e-e348-4bf9-aa71-65f0675147e4.jpg?1636769610"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Chain Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/4/14bd3d19-033e-41a7-8710-02b73ba0b4e4.jpg?1562899148", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/4/14bd3d19-033e-41a7-8710-02b73ba0b4e4.jpg?1562899148"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Chain Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/c/9ca05db2-ad92-4f4a-992d-b7f08f4f9c26.jpg?1562928035", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/c/9ca05db2-ad92-4f4a-992d-b7f08f4f9c26.jpg?1562928035"}, "reprint": true, "digital": false, "set_type": "premium_deck"}, {"name": "Chain of Plasma", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/9/f94aa774-9036-4016-8880-4bde2710cb90.jpg?1562954081", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/9/f94aa774-9036-4016-8880-4bde2710cb90.jpg?1562954081"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Chandra's Fury", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/7/e761acf6-6618-44cc-8f65-1d7ad7e520fe.jpg?1561758344", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/7/e761acf6-6618-44cc-8f65-1d7ad7e520fe.jpg?1561758344"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Chandra's Outburst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f1e849c3-f357-4e81-a580-be5056bed51b.jpg?1562745440", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f1e849c3-f357-4e81-a580-be5056bed51b.jpg?1562745440"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Chandra's Outrage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/2/3282db18-8564-418e-8c26-62e610b160f2.jpg?1562905547", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/2/3282db18-8564-418e-8c26-62e610b160f2.jpg?1562905547"}, "reprint": false, "digital": false, "set_type": "archenemy"}, {"name": "Char", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/f/ff3a24af-e995-4d05-ac2c-e9676048675d.jpg?1598915384", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/f/ff3a24af-e995-4d05-ac2c-e9676048675d.jpg?1598915384"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Char", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/d/3dc5f957-c1e4-452d-a78b-8d772ea0b940.jpg?1561756964", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/d/3dc5f957-c1e4-452d-a78b-8d772ea0b940.jpg?1561756964"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Cinder Cloud", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/0/f044c470-50ce-4a6c-b8ab-665357c3c11e.jpg?1562722408", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/0/f044c470-50ce-4a6c-b8ab-665357c3c11e.jpg?1562722408"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cinder Storm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e2d16c1-6226-438f-be1e-eaab3df687e1.jpg?1562875024", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e2d16c1-6226-438f-be1e-eaab3df687e1.jpg?1562875024"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Clan Defiance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/f/efa05298-9c94-4179-b75a-49ee2ca92920.jpg?1561851654", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/f/efa05298-9c94-4179-b75a-49ee2ca92920.jpg?1561851654"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cleansing Screech", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/79928b26-fcac-4c3f-9edd-292769c2e56e.jpg?1562131561", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/79928b26-fcac-4c3f-9edd-292769c2e56e.jpg?1562131561"}, "reprint": false, "digital": false, "set_type": "duel_deck"}, {"name": "Collateral Damage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/b/fb738362-b0b4-4811-9fbf-5f45c852c822.jpg?1562831834", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/b/fb738362-b0b4-4811-9fbf-5f45c852c822.jpg?1562831834"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Collective Defiance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/9/8960883f-3813-412b-9a5b-f8cf8d566fac.jpg?1576384546", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/9/8960883f-3813-412b-9a5b-f8cf8d566fac.jpg?1576384546"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Concussive Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/1/41b68e85-a381-441d-aa18-491f9e202a10.jpg?1562610848", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/1/41b68e85-a381-441d-aa18-491f9e202a10.jpg?1562610848"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cone of Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/e/bec5e56a-5bab-4965-9035-128c3f1ae175.jpg?1562554444", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/e/bec5e56a-5bab-4965-9035-128c3f1ae175.jpg?1562554444"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Cone of Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/7/5713f17a-9a57-41f8-b492-ced876e1a37f.jpg?1562800924", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/7/5713f17a-9a57-41f8-b492-ced876e1a37f.jpg?1562800924"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Consuming Sinkhole", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/2/82a42b28-3d1b-4432-b8c9-2d42e4d0e1c5.jpg?1562921426", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/2/82a42b28-3d1b-4432-b8c9-2d42e4d0e1c5.jpg?1562921426"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Crackling Doom", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f83c7d53-2599-42a9-ae96-a2699c5164cb.jpg?1562796251", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f83c7d53-2599-42a9-ae96-a2699c5164cb.jpg?1562796251"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Crater's Claws", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/5/95dde66b-b4a1-4a1e-8c9e-0bec4790b1e5.jpg?1562790652", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/5/95dde66b-b4a1-4a1e-8c9e-0bec4790b1e5.jpg?1562790652"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Creative Outburst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/a/eab58d87-bf01-45dc-8958-e2b3375f914b.jpg?1627428357", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/a/eab58d87-bf01-45dc-8958-e2b3375f914b.jpg?1627428357"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cryoclasm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/a/6a892711-a1a4-4402-957f-92077d00320d.jpg?1593275219", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/a/6a892711-a1a4-4402-957f-92077d00320d.jpg?1593275219"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Culmination of Studies", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/4/2483060e-9d3f-48ae-80ea-0119bf6b4d67.jpg?1627428427", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/4/2483060e-9d3f-48ae-80ea-0119bf6b4d67.jpg?1627428427"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cunning Strike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/4/e4991f81-3190-4d33-bf09-9d5387cbec11.jpg?1562830894", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/4/e4991f81-3190-4d33-bf09-9d5387cbec11.jpg?1562830894"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Darigaaz's Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/f/cf4c9d6a-86eb-45be-9405-473eb263b94c.jpg?1562938851", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/f/cf4c9d6a-86eb-45be-9405-473eb263b94c.jpg?1562938851"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Deal Damage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/de905517-983d-4996-a680-3a5cf91bfe11.jpg?1562489827", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/de905517-983d-4996-a680-3a5cf91bfe11.jpg?1562489827"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Death Spark", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba841b44-475c-402c-ac11-763de0cf27d9.jpg?1562770162", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba841b44-475c-402c-ac11-763de0cf27d9.jpg?1562770162"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Deflecting Palm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/2/32374918-1bcb-4516-96af-f27da752517e.jpg?1562784565", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/2/32374918-1bcb-4516-96af-f27da752517e.jpg?1562784565"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Demonfire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/f/af2ad333-722e-4d7e-972a-903c24068931.jpg?1593273111", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/f/af2ad333-722e-4d7e-972a-903c24068931.jpg?1593273111"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Destructive Revelry", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bc2eb53a-3d0f-4bb3-be36-f8024f2a1d4d.jpg?1592752246", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bc2eb53a-3d0f-4bb3-be36-f8024f2a1d4d.jpg?1592752246"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Detonate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/3/237eedf5-8a8f-4668-a911-e2bf66f8221e.jpg?1562138293", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/3/237eedf5-8a8f-4668-a911-e2bf66f8221e.jpg?1562138293"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Detonate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/f/ffd7eb90-ae95-49df-898a-9510187bce1c.jpg?1562949167", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/f/ffd7eb90-ae95-49df-898a-9510187bce1c.jpg?1562949167"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Devastate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/f/bfe7c990-a34b-475e-a612-447c22f998d3.jpg?1562930849", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/f/bfe7c990-a34b-475e-a612-447c22f998d3.jpg?1562930849"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Devil's Play", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/8/c80596a4-b464-4b9e-8186-94a1c44838eb.jpg?1562836883", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/8/c80596a4-b464-4b9e-8186-94a1c44838eb.jpg?1562836883"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Devil's Play", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e6dd2f9e-16c2-4d25-98c4-0017ccd42228.jpg?1561758340", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e6dd2f9e-16c2-4d25-98c4-0017ccd42228.jpg?1561758340"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Direct Current", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/6/166b0d75-824c-4c04-833b-7f7c69569a18.jpg?1572893128", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/6/166b0d75-824c-4c04-833b-7f7c69569a18.jpg?1572893128"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disintegrate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/7/8712c49e-f171-4669-bed9-87575a37af11.jpg?1559591574", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/7/8712c49e-f171-4669-bed9-87575a37af11.jpg?1559591574"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Disintegrate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/3/93ca09e6-2f23-4457-80ab-c7806112888b.jpg?1562546639", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/3/93ca09e6-2f23-4457-80ab-c7806112888b.jpg?1562546639"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Double Deal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/d/ed8b3def-30ee-4dd2-9a25-ecf7d5663f96.jpg?1562799187", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/d/ed8b3def-30ee-4dd2-9a25-ecf7d5663f96.jpg?1562799187"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Draconic Roar", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/c/6cf5591c-46e3-4904-8b4e-4f1f84d3118f.jpg?1562787954", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/c/6cf5591c-46e3-4904-8b4e-4f1f84d3118f.jpg?1562787954"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dragon's Approach", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/c/0cb504a0-1dfb-49d0-84c3-7bd318d55481.jpg?1624591696", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/c/0cb504a0-1dfb-49d0-84c3-7bd318d55481.jpg?1624591696"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/f/8f04dc5c-2764-42d0-974e-6d902222c138.jpg?1562242701", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/f/8f04dc5c-2764-42d0-974e-6d902222c138.jpg?1562242701"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/5/05126438-e806-43e6-bd81-233b629b4a1b.jpg?1562896224", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/5/05126438-e806-43e6-bd81-233b629b4a1b.jpg?1562896224"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/7/272f65a3-3c0c-417d-b5b6-276a643d643e.jpg?1562446144", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/7/272f65a3-3c0c-417d-b5b6-276a643d643e.jpg?1562446144"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/1/01bde909-899d-4efc-aac5-57b69fa764db.jpg?1562588740", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/1/01bde909-899d-4efc-aac5-57b69fa764db.jpg?1562588740"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e68ac362-6cdc-48a6-bdd3-4f8ea32add64.jpg?1559591701", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e68ac362-6cdc-48a6-bdd3-4f8ea32add64.jpg?1559591701"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Electrodominance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/c/5c63877b-cdab-4ce4-a1c0-c088eb62a57a.jpg?1584830858", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/c/5c63877b-cdab-4ce4-a1c0-c088eb62a57a.jpg?1584830858"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Electrostatic Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/6/36ba1ac9-ebb9-449d-bd3b-716631b112fb.jpg?1645416206", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/6/36ba1ac9-ebb9-449d-bd3b-716631b112fb.jpg?1645416206"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Ember Shot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/a/6a9eb72b-9ae2-4b64-bbb9-187446b5fd2f.jpg?1562630295", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/a/6a9eb72b-9ae2-4b64-bbb9-187446b5fd2f.jpg?1562630295"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Energy Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/1/711f4cff-0256-44b2-a2fe-1cae6e9edb2b.jpg?1562719783", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/711f4cff-0256-44b2-a2fe-1cae6e9edb2b.jpg?1562719783"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Essence Backlash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/9/a98609dc-ea90-4c7e-a191-5e5d0ba16847.jpg?1562791298", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/9/a98609dc-ea90-4c7e-a191-5e5d0ba16847.jpg?1562791298"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Eternal Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/6/d646feea-3c20-4737-8d20-ffad42258ced.jpg?1562946085", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/6/d646feea-3c20-4737-8d20-ffad42258ced.jpg?1562946085"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Exploding Borders", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f247aaaf-4d65-4dfc-bab2-3c1331762647.jpg?1562804623", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f247aaaf-4d65-4dfc-bab2-3c1331762647.jpg?1562804623"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Explosive Impact", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/a/3a3e2b45-b086-4ffd-aa1a-1d03046e0d61.jpg?1562785002", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/a/3a3e2b45-b086-4ffd-aa1a-1d03046e0d61.jpg?1562785002"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Explosive Singularity", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e6cdd822-44a1-4d58-9de4-69fc56eae255.jpg?1654567601", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e6cdd822-44a1-4d58-9de4-69fc56eae255.jpg?1654567601"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Explosive Singularity", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/1/a1d47e98-daae-42f7-9581-1269d57bd16e.jpg?1654570003", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/1/a1d47e98-daae-42f7-9581-1269d57bd16e.jpg?1654570003"}, "reprint": false, "frame_effects": ["showcase"], "digital": false, "set_type": "expansion"}, {"name": "Explosive Welcome", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/2/122c01e6-38a6-456e-971e-9004df85ac1c.jpg?1624591777", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/2/122c01e6-38a6-456e-971e-9004df85ac1c.jpg?1624591777"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Exquisite Firecraft", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/2/42eca98e-a164-4f70-a0b0-7a604863f30b.jpg?1562016890", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/2/42eca98e-a164-4f70-a0b0-7a604863f30b.jpg?1562016890"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Exquisite Firecraft", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/b/0be814f7-3c35-4b82-9fda-b8750a77cb9b.jpg?1562542837", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/b/0be814f7-3c35-4b82-9fda-b8750a77cb9b.jpg?1562542837"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Face to Face", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/64c93900-1af7-4c6b-a844-055bb7e27ddb.jpg?1562488406", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/64c93900-1af7-4c6b-a844-055bb7e27ddb.jpg?1562488406"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Fanning the Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/79075361-e6ee-4cc9-990b-88fef27bbb1c.jpg?1562596865", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/79075361-e6ee-4cc9-990b-88fef27bbb1c.jpg?1562596865"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Farideh's Fireball", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/7/57a46987-f05a-4b83-af56-f18000874e65.jpg?1627706196", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/7/57a46987-f05a-4b83-af56-f18000874e65.jpg?1627706196"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fateful End", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/6/56455067-92c0-45b5-ac2e-525c35b41215.jpg?1581480134", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/6/56455067-92c0-45b5-ac2e-525c35b41215.jpg?1581480134"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fault Line", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/a/cab4fd0e-9f84-4628-92a7-858ad8064531.jpg?1562937807", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/a/cab4fd0e-9f84-4628-92a7-858ad8064531.jpg?1562937807"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fiery Confluence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/b/7b61c9bc-16e8-417f-99e7-8bd83d4666c5.jpg?1562706203", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/b/7b61c9bc-16e8-417f-99e7-8bd83d4666c5.jpg?1562706203"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Fiery Confluence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4c454a20-8ec8-41d9-b9c3-acaa510d050b.jpg?1593559583", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4c454a20-8ec8-41d9-b9c3-acaa510d050b.jpg?1593559583"}, "reprint": true, "digital": false, "set_type": "spellbook"}, {"name": "Fiery Gambit", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/9/a91376ed-5868-4887-8389-5ef5b9471786.jpg?1562153660", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/9/a91376ed-5868-4887-8389-5ef5b9471786.jpg?1562153660"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fiery Temper", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/61caf82d-e077-4931-a6ad-09fa7f04b36f.jpg?1576384730", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/61caf82d-e077-4931-a6ad-09fa7f04b36f.jpg?1576384730"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Fiery Temper", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/1/918e46b7-cbca-4acf-8e83-94b5fcadcc49.jpg?1562630935", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/1/918e46b7-cbca-4acf-8e83-94b5fcadcc49.jpg?1562630935"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fiery Temper", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/3/73493d43-7952-4202-818d-a1a05788af6f.jpg?1562636814", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/3/73493d43-7952-4202-818d-a1a05788af6f.jpg?1562636814"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Fiery Temper", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d377a7b9-5c25-4017-84a8-ae368eceba50.jpg?1561758137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d377a7b9-5c25-4017-84a8-ae368eceba50.jpg?1561758137"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Fire Ambush", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/d/4dd8bdbd-99c9-4fa7-936a-acc7f4238507.jpg?1562256089", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/d/4dd8bdbd-99c9-4fa7-936a-acc7f4238507.jpg?1562256089"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Fireblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/4/44ab6601-409b-416f-a26c-b995e08fe6f3.jpg?1562908902", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/4/44ab6601-409b-416f-a26c-b995e08fe6f3.jpg?1562908902"}, "reprint": true, "digital": true, "set_type": "masters"}, {"name": "Fireblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b1eb5b2c-1f02-48a6-a287-88eb189d6780.jpg?1562278616", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b1eb5b2c-1f02-48a6-a287-88eb189d6780.jpg?1562278616"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Firebolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/0/90aae741-88af-4d21-a230-9a2592acdc87.jpg?1580014536", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/0/90aae741-88af-4d21-a230-9a2592acdc87.jpg?1580014536"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Firebolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/5/d5e45005-dd81-4d80-b043-02f719aca929.jpg?1562934963", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/5/d5e45005-dd81-4d80-b043-02f719aca929.jpg?1562934963"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Fires of Undeath", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/d/6d94aaa4-c2fd-4714-9198-8415158b9c4d.jpg?1562920799", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/d/6d94aaa4-c2fd-4714-9198-8415158b9c4d.jpg?1562920799"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fire Tempest", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/2/92334ebe-3d7a-46de-8b91-931e5d56a5a5.jpg?1562447336", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/2/92334ebe-3d7a-46de-8b91-931e5d56a5a5.jpg?1562447336"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "First Volley", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/6/d6e5e360-ed47-40c1-8ad7-57645c2854ca.jpg?1562880074", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/6/d6e5e360-ed47-40c1-8ad7-57645c2854ca.jpg?1562880074"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flamebreak", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/7/87e1f06f-7c87-4da8-b339-e571e391cab1.jpg?1562637920", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/7/87e1f06f-7c87-4da8-b339-e571e391cab1.jpg?1562637920"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/64bbd438-7df2-4d7b-88ad-4531ebaf3931.jpg?1562913643", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/64bbd438-7df2-4d7b-88ad-4531ebaf3931.jpg?1562913643"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Jab", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/6/06c2b6b2-485e-41e6-b106-4f6f402e0ec3.jpg?1562896430", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/6/06c2b6b2-485e-41e6-b106-4f6f402e0ec3.jpg?1562896430"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Javelin", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a567b570-81e4-4068-929c-9ce406fe7474.jpg?1562834196", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a567b570-81e4-4068-929c-9ce406fe7474.jpg?1562834196"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Javelin", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/0/407858c8-316d-47a7-8234-c490a0bc87a6.jpg?1561756991", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/0/407858c8-316d-47a7-8234-c490a0bc87a6.jpg?1561756991"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Flame Jet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a511f9df-b53b-4fea-87cd-9f18f6833f92.jpg?1562444727", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a511f9df-b53b-4fea-87cd-9f18f6833f92.jpg?1562444727"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Lash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/c/ac44e3cb-cc69-4222-87bc-ffa54b7ab34a.jpg?1562741297", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/c/ac44e3cb-cc69-4222-87bc-ffa54b7ab34a.jpg?1562741297"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Rift", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e63ed449-d249-4639-85d2-f8fe75496d5c.jpg?1626100460", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e63ed449-d249-4639-85d2-f8fe75496d5c.jpg?1626100460"}, "reprint": true, "digital": false, "set_type": "draft_innovation"}, {"name": "Flame Rift", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/7/7717eeb9-c457-4a65-93a0-e91c7f6a1970.jpg?1562630580", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/7/7717eeb9-c457-4a65-93a0-e91c7f6a1970.jpg?1562630580"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flames of the Blood Hand", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/7/a79701b4-d220-4c3e-b96c-7a77a22ba899.jpg?1651124386", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/7/a79701b4-d220-4c3e-b96c-7a77a22ba899.jpg?1651124386"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Spill", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/3/b3090004-d7dd-47bc-92e5-977be4fd9ae5.jpg?1591227197", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/3/b3090004-d7dd-47bc-92e5-977be4fd9ae5.jpg?1591227197"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flame Wave", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/0/e069d90a-e7d9-4967-a872-0dd8a0a9934a.jpg?1562597824", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/0/e069d90a-e7d9-4967-a872-0dd8a0a9934a.jpg?1562597824"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flaming Gambit", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/b/fb7fd9b7-c394-4ab3-b945-b4aab694eb6a.jpg?1562632851", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/b/fb7fd9b7-c394-4ab3-b945-b4aab694eb6a.jpg?1562632851"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Flare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/b/abc046c2-be9b-4f93-ac7d-e7dea6c4df9a.jpg?1562593271", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/b/abc046c2-be9b-4f93-ac7d-e7dea6c4df9a.jpg?1562593271"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Flare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1bd7755f-7ca5-4948-8baf-976823906891.jpg?1617148194", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1bd7755f-7ca5-4948-8baf-976823906891.jpg?1617148194"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Flare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/5/d5350236-7bd2-462d-9768-50087626c764.jpg?1562934818", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/5/d5350236-7bd2-462d-9768-50087626c764.jpg?1562934818"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Foundry Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/c/9c54b7c6-f94c-4349-8725-319c54240409.jpg?1626098329", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/c/9c54b7c6-f94c-4349-8725-319c54240409.jpg?1626098329"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Friendly Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/c/ac4272ca-bb15-415c-a589-a472953a0dd9.jpg?1562828722", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/c/ac4272ca-bb15-415c-a589-a472953a0dd9.jpg?1562828722"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Galvanic Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/5/f5881bbc-8600-464d-9dcd-5a7780918d1d.jpg?1562825173", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/5/f5881bbc-8600-464d-9dcd-5a7780918d1d.jpg?1562825173"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Geistblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/9/19b1dfd9-b717-4c23-b8e5-a6ec835b278a.jpg?1576384765", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/9/19b1dfd9-b717-4c23-b8e5-a6ec835b278a.jpg?1576384765"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Geistflame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1b856f31-ac80-4338-95a5-3f8acda74cfe.jpg?1562826976", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1b856f31-ac80-4338-95a5-3f8acda74cfe.jpg?1562826976"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ghitu Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/8/78827acd-a526-411b-bd22-ab9b538c75dd.jpg?1562919168", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/8/78827acd-a526-411b-bd22-ab9b538c75dd.jpg?1562919168"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ghostfire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/6/a60475e5-0d37-4af0-b717-da4c8dea45ac.jpg?1562928542", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/6/a60475e5-0d37-4af0-b717-da4c8dea45ac.jpg?1562928542"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Giant's Ire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/4/046fa2db-4c73-401a-b9a4-b039554be625.jpg?1562336735", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/4/046fa2db-4c73-401a-b9a4-b039554be625.jpg?1562336735"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Glacial Ray", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/4/04c713fd-df47-4b35-bd37-ab65d853bdc8.jpg?1562271537", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/4/04c713fd-df47-4b35-bd37-ab65d853bdc8.jpg?1562271537"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Goblin Barrage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/8/4849db5d-cd41-49f6-acd5-697cdc8263f6.jpg?1562735067", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/8/4849db5d-cd41-49f6-acd5-697cdc8263f6.jpg?1562735067"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Goblin Grenade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/9/394cc2aa-0318-4ccd-a550-99a7eac933c3.jpg?1562639104", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/9/394cc2aa-0318-4ccd-a550-99a7eac933c3.jpg?1562639104"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Goblin Grenade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/8/8837eaba-9602-4f63-9897-85583fcdcf51.jpg?1562920228", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/8/8837eaba-9602-4f63-9897-85583fcdcf51.jpg?1562920228"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Goblin Grenade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/dee262da-3002-4c08-8043-4e40e1b46822.jpg?1562936623", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/dee262da-3002-4c08-8043-4e40e1b46822.jpg?1562936623"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Goblin Grenade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1befdfc7-a1e3-4a2a-ad68-7d0fee170f3f.jpg?1562900237", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1befdfc7-a1e3-4a2a-ad68-7d0fee170f3f.jpg?1562900237"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Grapeshot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/c/8cd49f85-7dbd-4cb6-b916-2adee29bb745.jpg?1561967853", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/c/8cd49f85-7dbd-4cb6-b916-2adee29bb745.jpg?1561967853"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Grapeshot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4ee33cb6-768e-44a0-b6f4-b8638aa84330.jpg?1562911525", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4ee33cb6-768e-44a0-b6f4-b8638aa84330.jpg?1562911525"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Grapeshot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/9/b99b45df-9602-4037-a695-09decb5f21d7.jpg?1623890288", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/9/b99b45df-9602-4037-a695-09decb5f21d7.jpg?1623890288"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Guerrilla Tactics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/b/9bf3bac0-6e63-4bd3-bbd6-547f46c2d126.jpg?1562552299", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/b/9bf3bac0-6e63-4bd3-bbd6-547f46c2d126.jpg?1562552299"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Guerrilla Tactics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/3/63535f0e-dc14-420e-bcb7-b5ef8fafb93f.jpg?1562915110", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/3/63535f0e-dc14-420e-bcb7-b5ef8fafb93f.jpg?1562915110"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Guerrilla Tactics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/1/51811f2a-7002-4ba7-98d8-5b09d887975c.jpg?1562768705", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/1/51811f2a-7002-4ba7-98d8-5b09d887975c.jpg?1562768705"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Guerrilla Tactics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/c/3c005ca3-0508-4ac2-afec-3d4a27334c31.jpg?1562768254", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/c/3c005ca3-0508-4ac2-afec-3d4a27334c31.jpg?1562768254"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Gut Shot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a54a2a30-b96a-49c7-9151-1f4b0d4a4413.jpg?1562880417", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a54a2a30-b96a-49c7-9151-1f4b0d4a4413.jpg?1562880417"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hammer of Bogardan", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/7/f7285f52-5df0-4f90-9cf7-a57295d90fd4.jpg?1562722857", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/7/f7285f52-5df0-4f90-9cf7-a57295d90fd4.jpg?1562722857"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hanabi Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/8/881fecf4-8c14-4614-84bd-c1a3dcdbb5ff.jpg?1562762458", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/8/881fecf4-8c14-4614-84bd-c1a3dcdbb5ff.jpg?1562762458"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Heartfire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/d/7db219ea-2ed1-4a86-955c-d61ecedbc019.jpg?1557576716", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/d/7db219ea-2ed1-4a86-955c-d61ecedbc019.jpg?1557576716"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hidetsugu's Second Rite", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/e/2e48eb77-3bd7-444a-9262-799cc706c05a.jpg?1562493025", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/e/2e48eb77-3bd7-444a-9262-799cc706c05a.jpg?1562493025"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hungry Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4ca23676-f36f-4266-ba4f-5e9ebf3adb57.jpg?1592419490", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4ca23676-f36f-4266-ba4f-5e9ebf3adb57.jpg?1592419490"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Igneous Inspiration", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/7/5781ad7b-dc1b-4cc1-9e72-6e714b9ba1de.jpg?1624591976", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/7/5781ad7b-dc1b-4cc1-9e72-6e714b9ba1de.jpg?1624591976"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Illuminate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/e/ceef2761-7301-42de-8f54-49b8cd1e457b.jpg?1562943833", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/e/ceef2761-7301-42de-8f54-49b8cd1e457b.jpg?1562943833"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Improvised Weaponry", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/9/29d5fd00-c616-4079-a91e-4da0bcaf9120.jpg?1627706453", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/9/29d5fd00-c616-4079-a91e-4da0bcaf9120.jpg?1627706453"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Incendiary Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/1/512367a2-f8f6-4c28-9eb3-8e04d2694e4b.jpg?1562348065", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/1/512367a2-f8f6-4c28-9eb3-8e04d2694e4b.jpg?1562348065"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Incendiary Flow", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/f/cf464f61-8a7f-493b-a80f-2f2b0ebd8bf6.jpg?1576384613", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/f/cf464f61-8a7f-493b-a80f-2f2b0ebd8bf6.jpg?1576384613"}, "reprint": false, "digital": false, "set_type": "expansion"}]} \ No newline at end of file diff --git a/web/public/mtg/jsons/burn2.json b/web/public/mtg/jsons/burn2.json new file mode 100644 index 00000000..c1116e11 --- /dev/null +++ b/web/public/mtg/jsons/burn2.json @@ -0,0 +1 @@ +{"has_more": true, "data": [{"name": "Incendiary Flow", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/ae93a313-c265-435f-b745-7b7a7ed6208e.jpg?1562636873", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/ae93a313-c265-435f-b745-7b7a7ed6208e.jpg?1562636873"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/5/8503210d-be78-4271-a050-53caa94f735d.jpg?1562844302", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/5/8503210d-be78-4271-a050-53caa94f735d.jpg?1562844302"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/2/723fb62e-735a-4ca6-9d38-f1c3944fe69a.jpg?1562549678", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/2/723fb62e-735a-4ca6-9d38-f1c3944fe69a.jpg?1562549678"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/a/aa0f7e1f-bcb5-414f-a2e9-6a158fec2ff5.jpg?1562593262", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/a/aa0f7e1f-bcb5-414f-a2e9-6a158fec2ff5.jpg?1562593262"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/0/409b2be8-5bb6-45e0-ab87-ca73b4e3a396.jpg?1562718795", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/0/409b2be8-5bb6-45e0-ab87-ca73b4e3a396.jpg?1562718795"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/c/9c3f00af-010d-4485-b8b7-47400d99c496.jpg?1562924091", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/c/9c3f00af-010d-4485-b8b7-47400d99c496.jpg?1562924091"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Incinerate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/8/28b0495d-0c3f-4491-8331-4cbabbd6eac5.jpg?1561756819", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/8/28b0495d-0c3f-4491-8331-4cbabbd6eac5.jpg?1561756819"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Inescapable Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/6/46651efd-0906-4350-a1b8-52e3f8aff45d.jpg?1572893201", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/6/46651efd-0906-4350-a1b8-52e3f8aff45d.jpg?1572893201"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Inferno", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/4/e411b7b5-ab91-410a-af6d-b3a21a8e3b70.jpg?1562249896", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/4/e411b7b5-ab91-410a-af6d-b3a21a8e3b70.jpg?1562249896"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Inferno", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/8/68d04a75-647f-400f-b0dc-c4544f7db2d4.jpg?1562591355", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/8/68d04a75-647f-400f-b0dc-c4544f7db2d4.jpg?1562591355"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Inferno", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/6/a6b61512-5b24-424c-966f-36b595781e14.jpg?1562934483", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/6/a6b61512-5b24-424c-966f-36b595781e14.jpg?1562934483"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Inferno", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/a/3ac1649a-629b-4598-be09-74a57905753f.jpg?1562544107", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/a/3ac1649a-629b-4598-be09-74a57905753f.jpg?1562544107"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Inferno Jet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/c/0c6a43fe-369d-4943-a825-570eb3cceba4.jpg?1562788752", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/c/0c6a43fe-369d-4943-a825-570eb3cceba4.jpg?1562788752"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Inspired Ultimatum", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/d/dd64f064-8f05-41ef-b95b-1b723137f846.jpg?1591228071", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/d/dd64f064-8f05-41ef-b95b-1b723137f846.jpg?1591228071"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Invoke the Firemind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/8/58d8e41a-5990-4ceb-9d41-76632faa7883.jpg?1593272700", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/8/58d8e41a-5990-4ceb-9d41-76632faa7883.jpg?1593272700"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ionize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f161f7d2-eaa1-4931-93f9-befa8b5df821.jpg?1572893679", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f161f7d2-eaa1-4931-93f9-befa8b5df821.jpg?1572893679"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Izzet Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/61289196-a56b-4d24-b340-9cf067c77f45.jpg?1592713417", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/61289196-a56b-4d24-b340-9cf067c77f45.jpg?1592713417"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Izzet Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e8e84a97-8e40-42fa-a114-df90e820ede6.jpg?1562497263", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e8e84a97-8e40-42fa-a114-df90e820ede6.jpg?1562497263"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Jeskai Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/a/ca268705-ef04-4bf1-8a5d-866bb3e5bb61.jpg?1562793488", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/a/ca268705-ef04-4bf1-8a5d-866bb3e5bb61.jpg?1562793488"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kaervek's Purge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/a/7a42ef95-92ec-40fe-ab30-a476f012a525.jpg?1562720237", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/a/7a42ef95-92ec-40fe-ab30-a476f012a525.jpg?1562720237"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kaervek's Torch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/a/0a1624ab-e50e-48a3-acf7-457069914616.jpg?1562717831", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/a/0a1624ab-e50e-48a3-acf7-457069914616.jpg?1562717831"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kaleidoscorch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a5f07603-fd79-437a-9b12-495fc5a39b68.jpg?1626096801", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a5f07603-fd79-437a-9b12-495fc5a39b68.jpg?1626096801"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Kamahl's Sledge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/8/38c55518-7bdf-4a42-ae30-cd6525557a59.jpg?1562629270", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/8/38c55518-7bdf-4a42-ae30-cd6525557a59.jpg?1562629270"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kami's Flare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/e/bef5d58e-b490-4682-9a44-12cd61a94c0f.jpg?1654567705", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/e/bef5d58e-b490-4682-9a44-12cd61a94c0f.jpg?1654567705"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kindle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/3/930745eb-b038-4b55-97f3-bf8d99b54d32.jpg?1562055431", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/3/930745eb-b038-4b55-97f3-bf8d99b54d32.jpg?1562055431"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kolaghan's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/c/7c884e1e-fecb-4330-b3de-5fc2a60f7173.jpg?1562788780", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/c/7c884e1e-fecb-4330-b3de-5fc2a60f7173.jpg?1562788780"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kolaghan's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/e/1e8bdd10-0bdc-4339-bd84-b540606438d6.jpg?1656000872", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/e/1e8bdd10-0bdc-4339-bd84-b540606438d6.jpg?1656000872"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "masters"}, {"name": "Lash Out", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/de2c0c8b-5442-44fb-9686-d3dff5742501.jpg?1562371092", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/de2c0c8b-5442-44fb-9686-d3dff5742501.jpg?1562371092"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lava Axe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/0/807e5102-1fab-4ff4-aad8-94defbbb8a6b.jpg?1562241656", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/0/807e5102-1fab-4ff4-aad8-94defbbb8a6b.jpg?1562241656"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Lava Axe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/1/e11ec278-46f5-4970-ad0b-f6718c73de6c.jpg?1562864233", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/1/e11ec278-46f5-4970-ad0b-f6718c73de6c.jpg?1562864233"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Lava Axe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/e/fe6cff90-ecec-4610-82ea-0f2a109959cf.jpg?1562955255", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/e/fe6cff90-ecec-4610-82ea-0f2a109959cf.jpg?1562955255"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Lava Axe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f2bebbad-76aa-4388-891a-583e8af9509d.jpg?1562448334", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f2bebbad-76aa-4388-891a-583e8af9509d.jpg?1562448334"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Lava Blister", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/d/cd0e9e53-2710-4c2a-a8e4-48f25375ebc7.jpg?1562933365", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/d/cd0e9e53-2710-4c2a-a8e4-48f25375ebc7.jpg?1562933365"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lava Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/79dc0e20-5790-4927-8432-cf0e9b7381d4.jpg?1562917534", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/79dc0e20-5790-4927-8432-cf0e9b7381d4.jpg?1562917534"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lava Dart", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b16dd041-451d-4914-8c46-aa315a90d802.jpg?1562201890", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b16dd041-451d-4914-8c46-aa315a90d802.jpg?1562201890"}, "reprint": true, "digital": false, "set_type": "draft_innovation"}, {"name": "Lava Dart", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/6/865bb1d3-5b7d-40e9-87cc-96be9524a105.jpg?1562630775", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/6/865bb1d3-5b7d-40e9-87cc-96be9524a105.jpg?1562630775"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Lavalanche", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/4/749981d6-78e7-4f53-80a8-f211e61bd532.jpg?1562642149", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/4/749981d6-78e7-4f53-80a8-f211e61bd532.jpg?1562642149"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lava Spike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/79c21c1f-eaa4-454d-a1c7-b41466d0a428.jpg?1547517298", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/79c21c1f-eaa4-454d-a1c7-b41466d0a428.jpg?1547517298"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Lava Spike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lightning Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/3/83e3c502-9e3c-41db-806c-538243dc0453.jpg?1562241728", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/3/83e3c502-9e3c-41db-806c-538243dc0453.jpg?1562241728"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Lightning Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/3/63fec3f9-d399-48e6-84b6-c8410c24c382.jpg?1562054251", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/3/63fec3f9-d399-48e6-84b6-c8410c24c382.jpg?1562054251"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/ae5f9fb1-5a55-4db3-98a1-2628e3598c18.jpg?1648155765", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/ae5f9fb1-5a55-4db3-98a1-2628e3598c18.jpg?1648155765"}, "reprint": true, "digital": false, "set_type": "draft_innovation"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/3/435589bb-27c6-4a6d-9d63-394d5092b9d8.jpg?1561978182", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/3/435589bb-27c6-4a6d-9d63-394d5092b9d8.jpg?1561978182"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/5/d573ef03-4730-45aa-93dd-e45ac1dbaf4a.jpg?1559591645", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/5/d573ef03-4730-45aa-93dd-e45ac1dbaf4a.jpg?1559591645"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/8/c8c8390f-4072-454f-8dc4-174919187a47.jpg?1655641560", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/8/c8c8390f-4072-454f-8dc4-174919187a47.jpg?1655641560"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "masters"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/6/c69f668b-cf28-495a-bbe1-24e9d0089fa1.jpg?1648155788", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/6/c69f668b-cf28-495a-bbe1-24e9d0089fa1.jpg?1648155788"}, "reprint": true, "frame_effects": ["showcase"], "digital": false, "set_type": "draft_innovation"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/7/27740ea5-79c8-420f-bc49-6d5eac58dac5.jpg?1657119952", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/7/27740ea5-79c8-420f-bc49-6d5eac58dac5.jpg?1657119952"}, "flavor_name": "Hadoken", "reprint": true, "digital": false, "set_type": "box"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4eaac4fd-95f5-4f38-b593-0101e79a20f9.jpg?1623945607", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4eaac4fd-95f5-4f38-b593-0101e79a20f9.jpg?1623945607"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/5/45184cd7-b037-4a85-a063-e622ca928d17.jpg?1599352446", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/5/45184cd7-b037-4a85-a063-e622ca928d17.jpg?1599352446"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/f/6fb94c1b-8002-4d79-add0-c4dfef9019ee.jpg?1599352358", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/f/6fb94c1b-8002-4d79-add0-c4dfef9019ee.jpg?1599352358"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/a/6ab06973-6440-4b12-8947-8c412500fa41.jpg?1599352361", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/a/6ab06973-6440-4b12-8947-8c412500fa41.jpg?1599352361"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/3/c3eb3895-b64c-46ab-b704-3c46963920ba.jpg?1599352414", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/3/c3eb3895-b64c-46ab-b704-3c46963920ba.jpg?1599352414"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/f/ff204024-20a5-4bb9-82b6-f6b4337efd60.jpg?1552226335", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/f/ff204024-20a5-4bb9-82b6-f6b4337efd60.jpg?1552226335"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Lightning Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/8/28708c8c-4336-4d04-b43a-59a31471a9f6.jpg?1561756817", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/8/28708c8c-4336-4d04-b43a-59a31471a9f6.jpg?1561756817"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Lightning Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/613789fe-fac1-4200-b0a1-c84d1fa27cff.jpg?1562917870", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/613789fe-fac1-4200-b0a1-c84d1fa27cff.jpg?1562917870"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Lightning Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1b2ecf55-c1cc-4b28-b7ce-e1b25305155e.jpg?1598917140", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1b2ecf55-c1cc-4b28-b7ce-e1b25305155e.jpg?1598917140"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lightning Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/227ac87a-7196-40d0-ab00-98ebafcca09a.jpg?1624065725", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/227ac87a-7196-40d0-ab00-98ebafcca09a.jpg?1624065725"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Lightning Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4ec9e67b-1b4e-4e4e-9758-be697d308f16.jpg?1561757108", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4ec9e67b-1b4e-4e4e-9758-be697d308f16.jpg?1561757108"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Lightning Javelin", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/1/c1ccaeed-9670-4432-8a45-d5c06119fa9f.jpg?1562040115", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/1/c1ccaeed-9670-4432-8a45-d5c06119fa9f.jpg?1562040115"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Lightning Storm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/9/c9c0388e-a04c-4757-a06d-8e8046f5a783.jpg?1593275279", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/9/c9c0388e-a04c-4757-a06d-8e8046f5a783.jpg?1593275279"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lightning Strike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/0/f0f55dee-7e39-4183-8e74-844d9c299bf5.jpg?1562566447", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/0/f0f55dee-7e39-4183-8e74-844d9c299bf5.jpg?1562566447"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Lightning Strike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/b/bbb03f2e-2b92-4aa1-afae-301ed5d151d3.jpg?1562827848", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/b/bbb03f2e-2b92-4aa1-afae-301ed5d151d3.jpg?1562827848"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lightning Surge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/4/0452d78d-eafc-4ccb-a478-d1f46bcefffe.jpg?1562628459", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/4/0452d78d-eafc-4ccb-a478-d1f46bcefffe.jpg?1562628459"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Light Up the Night", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/d/7de68154-3b82-4a94-98a6-cfc49d359e4e.jpg?1636223152", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/d/7de68154-3b82-4a94-98a6-cfc49d359e4e.jpg?1636223152"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lorehold Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/4/e4f0885f-1049-4a19-853d-f4e6d4bec29e.jpg?1627429447", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/4/e4f0885f-1049-4a19-853d-f4e6d4bec29e.jpg?1627429447"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lunge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/9/e9e43349-429c-43f7-b808-c4bf37370a9f.jpg?1562383530", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/9/e9e43349-429c-43f7-b808-c4bf37370a9f.jpg?1562383530"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Magma Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/9/d9752bc3-0bdf-4657-8750-73c8cbc8e83f.jpg?1562940942", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/9/d9752bc3-0bdf-4657-8750-73c8cbc8e83f.jpg?1562940942"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Magma Jet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/a/8af1c5b0-973d-467e-a797-51ca75c183c1.jpg?1593813497", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/a/8af1c5b0-973d-467e-a797-51ca75c183c1.jpg?1593813497"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Magma Jet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/1/51ea1728-08aa-4553-90b2-919c70712ed5.jpg?1562877009", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/1/51ea1728-08aa-4553-90b2-919c70712ed5.jpg?1562877009"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Magma Jet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/e/be7552ac-4546-492d-8d11-d6678a04b9c3.jpg?1562640021", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/e/be7552ac-4546-492d-8d11-d6678a04b9c3.jpg?1562640021"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Make Mischief", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/2/a2049072-5901-4edd-8305-ce55f256bca5.jpg?1576384624", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/2/a2049072-5901-4edd-8305-ce55f256bca5.jpg?1576384624"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Melt Terrain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/d/1d94a1d1-6d24-46e1-9568-42e1a810ad31.jpg?1562815251", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/d/1d94a1d1-6d24-46e1-9568-42e1a810ad31.jpg?1562815251"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mindblaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/9/59418766-5567-4ec4-af1f-1cb2db2958d0.jpg?1562760146", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/9/59418766-5567-4ec4-af1f-1cb2db2958d0.jpg?1562760146"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mindswipe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/5/557e8303-a021-4257-b41a-7d25f04618c8.jpg?1562786781", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/5/557e8303-a021-4257-b41a-7d25f04618c8.jpg?1562786781"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Misfortune", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b14cc32a-eb4f-4690-aceb-160780743ebe.jpg?1562770145", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b14cc32a-eb4f-4690-aceb-160780743ebe.jpg?1562770145"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Molten Disaster", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/1/31e0713c-dbf4-4403-ae69-58fd483e2481.jpg?1562905110", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/1/31e0713c-dbf4-4403-ae69-58fd483e2481.jpg?1562905110"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Molten Influence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4c2b326b-d177-4a03-a0a3-fe2c2d4af272.jpg?1562908953", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4c2b326b-d177-4a03-a0a3-fe2c2d4af272.jpg?1562908953"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Molten Rain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/c/ecdd414b-3d9d-4347-acce-289209d09fc4.jpg?1593813519", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/c/ecdd414b-3d9d-4347-acce-289209d09fc4.jpg?1593813519"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Molten Rain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f888b4d4-31f9-4322-8225-4d7e7a9f4dd5.jpg?1562163535", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f888b4d4-31f9-4322-8225-4d7e7a9f4dd5.jpg?1562163535"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Moonrager's Slash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/b/eb4f266b-c41c-4047-ae6f-b2226c7459e8.jpg?1636223196", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/b/eb4f266b-c41c-4047-ae6f-b2226c7459e8.jpg?1636223196"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Needle Drop", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d3f89bcf-46f8-4598-a949-7f10134606aa.jpg?1562369628", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d3f89bcf-46f8-4598-a949-7f10134606aa.jpg?1562369628"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Neonate's Rush", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/dee17e12-e08f-4449-9f49-05f20e0d1670.jpg?1636223275", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/dee17e12-e08f-4449-9f49-05f20e0d1670.jpg?1636223275"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Nerf War", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/e/2eb08fc5-29a4-4911-ac94-dc5ff2fc2ace.jpg?1561756860", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/e/2eb08fc5-29a4-4911-ac94-dc5ff2fc2ace.jpg?1561756860"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Open Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/4/448f9fb5-ffb5-4325-9f81-ce8782e5f9e9.jpg?1562797508", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/4/448f9fb5-ffb5-4325-9f81-ce8782e5f9e9.jpg?1562797508"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Orcish Cannonade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/a/0afae574-aa96-4500-9882-a4b10337b6f5.jpg?1619397326", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/a/0afae574-aa96-4500-9882-a4b10337b6f5.jpg?1619397326"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Orcish Cannonade", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4e40f99c-9608-4463-8c6f-c6e142f0d716.jpg?1562911399", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4e40f99c-9608-4463-8c6f-c6e142f0d716.jpg?1562911399"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Parallectric Feedback", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/9/891f1d29-377a-4f71-917f-ff10e785caee.jpg?1593272344", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/9/891f1d29-377a-4f71-917f-ff10e785caee.jpg?1593272344"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Parch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d3ab8065-cecc-4b19-be93-7cf791a93e62.jpg?1562864229", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d3ab8065-cecc-4b19-be93-7cf791a93e62.jpg?1562864229"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pass the Torch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/d/5dc05455-4ebd-46f8-94cf-14f0d5420037.jpg?1654043945", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/d/5dc05455-4ebd-46f8-94cf-14f0d5420037.jpg?1654043945"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Peak Eruption", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/d/ed0a00f7-aee0-4ab2-bab6-bc0949176a7a.jpg?1562836713", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/d/ed0a00f7-aee0-4ab2-bab6-bc0949176a7a.jpg?1562836713"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pigment Storm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/2/d285a7a1-bb7e-4a78-a49f-c2add62b829a.jpg?1624592111", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/2/d285a7a1-bb7e-4a78-a49f-c2add62b829a.jpg?1624592111"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pillar of Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/9/c983e879-d9d2-47cc-9958-506711ca80cd.jpg?1592709165", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/9/c983e879-d9d2-47cc-9958-506711ca80cd.jpg?1592709165"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pillar of Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/3/c39677b8-9a43-4e62-a83a-4a9d6372310b.jpg?1562640029", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/3/c39677b8-9a43-4e62-a83a-4a9d6372310b.jpg?1562640029"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Play with Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/2/42901bec-a8d0-46a3-a710-bfb7bd87f155.jpg?1640721639", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/2/42901bec-a8d0-46a3-a710-bfb7bd87f155.jpg?1640721639"}, "reprint": false, "frame_effects": ["inverted"], "digital": false, "set_type": "expansion"}, {"name": "Poison the Well", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/b/cb86eeec-d50f-4823-86bd-35437926a6e4.jpg?1562835997", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/b/cb86eeec-d50f-4823-86bd-35437926a6e4.jpg?1562835997"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Precision Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/5/a59b4e5b-e9e0-4507-b9e7-8fba7e3a54f9.jpg?1572894271", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/5/a59b4e5b-e9e0-4507-b9e7-8fba7e3a54f9.jpg?1572894271"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Prismari Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/6/866b7fd4-86e3-4b42-b1ea-33bad0db1f9f.jpg?1627429955", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/6/866b7fd4-86e3-4b42-b1ea-33bad0db1f9f.jpg?1627429955"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Prophetic Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/0/101163c0-cd2f-4e1a-84b3-f64fc748807d.jpg?1592713462", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/0/101163c0-cd2f-4e1a-84b3-f64fc748807d.jpg?1592713462"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Prophetic Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/79f74291-c452-4a60-bf5f-73efad6583d4.jpg?1562923762", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/79f74291-c452-4a60-bf5f-73efad6583d4.jpg?1562923762"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Provoke the Trolls", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/7/2727b05a-0c86-4c59-b7b4-425bdd8e775d.jpg?1631049503", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/7/2727b05a-0c86-4c59-b7b4-425bdd8e775d.jpg?1631049503"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pulse of the Forge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/a/ea3ed9c8-b552-4a9a-b77a-8b148638b4f0.jpg?1562640294", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/a/ea3ed9c8-b552-4a9a-b77a-8b148638b4f0.jpg?1562640294"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Puncture Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/b/3bf90b4d-98cf-4953-b6ae-c41d21ab559b.jpg?1562907480", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/b/3bf90b4d-98cf-4953-b6ae-c41d21ab559b.jpg?1562907480"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Punishing Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0da4409b-fe3f-4500-bf4b-890593f7d313.jpg?1562897775", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0da4409b-fe3f-4500-bf4b-890593f7d313.jpg?1562897775"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Punishing Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/6/56e76f1c-5a07-455a-a3df-4c45b5b25b82.jpg?1562612350", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/6/56e76f1c-5a07-455a-a3df-4c45b5b25b82.jpg?1562612350"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Punish the Enemy", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/1/4179a72b-8482-46ec-9815-f5d6d94b5aa5.jpg?1562907014", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/1/4179a72b-8482-46ec-9815-f5d6d94b5aa5.jpg?1562907014"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pyromatics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/2/c22c9dab-e8d5-48b3-8fd2-9f4138ee0c7c.jpg?1593272350", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/2/c22c9dab-e8d5-48b3-8fd2-9f4138ee0c7c.jpg?1593272350"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Quenchable Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/e/ee1c0ded-2a80-4ed4-b9fc-3a18bf5c3239.jpg?1562804519", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/e/ee1c0ded-2a80-4ed4-b9fc-3a18bf5c3239.jpg?1562804519"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rain of Embers", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/d/2d5391a9-6c30-4f9b-b746-a4427a3e63fc.jpg?1598915805", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/d/2d5391a9-6c30-4f9b-b746-a4427a3e63fc.jpg?1598915805"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rakdos Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/f/0fcd4394-d22d-4eec-ad73-ffaf10ad60de.jpg?1562782720", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/f/0fcd4394-d22d-4eec-ad73-ffaf10ad60de.jpg?1562782720"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rakdos's Return", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/7/d72981c0-1632-4d64-9341-2a76047d9b36.jpg?1562793869", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/7/d72981c0-1632-4d64-9341-2a76047d9b36.jpg?1562793869"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ral's Outburst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/b/6be3dd3e-50d2-4729-9caa-b2cd984f4c97.jpg?1557577237", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/b/6be3dd3e-50d2-4729-9caa-b2cd984f4c97.jpg?1557577237"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ravaging Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/d/5d9404b2-f0ea-4a31-bc7b-6748574c57d3.jpg?1562021972", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/d/5d9404b2-f0ea-4a31-bc7b-6748574c57d3.jpg?1562021972"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Reality Hemorrhage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c044168d-cb08-493d-98c1-b66b6149fe5a.jpg?1562933647", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c044168d-cb08-493d-98c1-b66b6149fe5a.jpg?1562933647"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Reckless Abandon", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/f/8f335d43-cacb-40ad-93c1-9a861e9f66c7.jpg?1562444699", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/f/8f335d43-cacb-40ad-93c1-9a861e9f66c7.jpg?1562444699"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Red Sun's Zenith", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/7/373eb109-0e30-41c1-b2df-6bc78d968890.jpg?1562610602", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/7/373eb109-0e30-41c1-b2df-6bc78d968890.jpg?1562610602"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rekindled Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/3/131c6377-4ed4-4a76-a9cb-be7ad17d76fd.jpg?1562899037", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/3/131c6377-4ed4-4a76-a9cb-be7ad17d76fd.jpg?1562899037"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Release the Ants", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1b6f1afb-2451-4611-ac3e-3513a4651719.jpg?1562877157", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1b6f1afb-2451-4611-ac3e-3513a4651719.jpg?1562877157"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rending Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/1/51332c31-41df-4379-aa63-6a734a4df618.jpg?1643591905", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/1/51332c31-41df-4379-aa63-6a734a4df618.jpg?1643591905"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Repeating Barrage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba90a2d6-8292-4ff1-91d0-b30ae9775f12.jpg?1562562987", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba90a2d6-8292-4ff1-91d0-b30ae9775f12.jpg?1562562987"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Resounding Thunder", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/8/680b7955-d939-4195-aba8-b46a8c925616.jpg?1562704894", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/8/680b7955-d939-4195-aba8-b46a8c925616.jpg?1562704894"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rhystic Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/1/21ce365e-3002-42e9-aeb5-1b845408271e.jpg?1562901251", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/1/21ce365e-3002-42e9-aeb5-1b845408271e.jpg?1562901251"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rift Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/8/88dde96e-6824-4d26-9fb5-86b9f3c50959.jpg?1562923901", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/8/88dde96e-6824-4d26-9fb5-86b9f3c50959.jpg?1562923901"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rift Bolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/5/25176fe7-a5a0-44d2-9619-193063c55902.jpg?1561929531", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/5/25176fe7-a5a0-44d2-9619-193063c55902.jpg?1561929531"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Risk Factor", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4eda89d9-9bd1-4a55-ac02-f9a0625d8e5b.jpg?1572893240", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4eda89d9-9bd1-4a55-ac02-f9a0625d8e5b.jpg?1572893240"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Roil Eruption", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/6/86572747-8faa-4242-b059-07d11e6be1cd.jpg?1604197631", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/6/86572747-8faa-4242-b059-07d11e6be1cd.jpg?1604197631"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Roiling Terrain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/7/87d3a425-01d1-4001-92f9-8e297dd862b7.jpg?1562291349", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/7/87d3a425-01d1-4001-92f9-8e297dd862b7.jpg?1562291349"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rolling Earthquake", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/c/3c1bf210-ecdb-4b49-8504-51360c269e66.jpg?1562256070", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/c/3c1bf210-ecdb-4b49-8504-51360c269e66.jpg?1562256070"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Sacred Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c0b1dcd7-6dd9-4134-bc6c-9dc7754006a2.jpg?1636684880", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c0b1dcd7-6dd9-4134-bc6c-9dc7754006a2.jpg?1636684880"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sarkhan's Catharsis", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/f/2f4b6f26-c66b-4048-9503-af0a886ef14f.jpg?1557576811", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/f/2f4b6f26-c66b-4048-9503-af0a886ef14f.jpg?1557576811"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sarkhan's Dragonfire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/9/b96a7320-089a-4a7e-813f-49ca1620df76.jpg?1562303870", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/9/b96a7320-089a-4a7e-813f-49ca1620df76.jpg?1562303870"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Sarkhan's Rage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/7/4787924f-3186-4e18-b53c-dd67c5f42220.jpg?1562785591", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/7/4787924f-3186-4e18-b53c-dd67c5f42220.jpg?1562785591"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Saut\u00e9", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/5/85cbebbb-7ea4-4140-933f-186cad08697d.jpg?1562488867", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/5/85cbebbb-7ea4-4140-933f-186cad08697d.jpg?1562488867"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Scent of Cinder", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c030eca0-bc5f-403b-8600-1f295fc85fee.jpg?1562445189", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c030eca0-bc5f-403b-8600-1f295fc85fee.jpg?1562445189"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scent of Cinder", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/0/d083fcdc-1e1f-4ad3-82d1-11f0b84dd74d.jpg?1562934001", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/0/d083fcdc-1e1f-4ad3-82d1-11f0b84dd74d.jpg?1562934001"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Scorching Lava", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/a/2a85437f-052e-494c-a9ee-265c4624a409.jpg?1562903659", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/a/2a85437f-052e-494c-a9ee-265c4624a409.jpg?1562903659"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scorching Missile", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/6/0672960b-4cb5-4ed6-ba3c-6b97290e0330.jpg?1562896294", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/6/0672960b-4cb5-4ed6-ba3c-6b97290e0330.jpg?1562896294"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Scorching Spear", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e4817bd-68e8-4a85-983a-ee6dda2bbf33.jpg?1562447352", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e4817bd-68e8-4a85-983a-ee6dda2bbf33.jpg?1562447352"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Searing Barrage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/2/d2f11135-e9ce-4e4c-bea7-72a46d326e40.jpg?1572490453", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/2/d2f11135-e9ce-4e4c-bea7-72a46d326e40.jpg?1572490453"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Searing Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/0/301f13dd-39b8-4a93-9c05-3dc4fa1f1c75.jpg?1562284687", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/0/301f13dd-39b8-4a93-9c05-3dc4fa1f1c75.jpg?1562284687"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Searing Blaze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/7/974c599b-170e-4948-b741-47f61c769b6e.jpg?1561757630", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/7/974c599b-170e-4948-b741-47f61c769b6e.jpg?1561757630"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Searing Blood", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/b/bb43fd07-d281-447d-88bf-c53498c2cf20.jpg?1593092367", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/b/bb43fd07-d281-447d-88bf-c53498c2cf20.jpg?1593092367"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Searing Flesh", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/8/d83db110-42e7-4823-a686-b83205faf503.jpg?1562946564", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/8/d83db110-42e7-4823-a686-b83205faf503.jpg?1562946564"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Searing Spear", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/1/11a94b7c-0216-473c-87a6-71e5a64d7799.jpg?1562550529", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/1/11a94b7c-0216-473c-87a6-71e5a64d7799.jpg?1562550529"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Searing Spear", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/5/e574f1f8-ca61-43b4-8230-2636709a3855.jpg?1562497262", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e574f1f8-ca61-43b4-8230-2636709a3855.jpg?1562497262"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Searing Touch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/9/e9091667-d5a8-4978-9023-032ff65f9642.jpg?1562057345", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/9/e9091667-d5a8-4978-9023-032ff65f9642.jpg?1562057345"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Searing Wind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/b/7b761f97-3690-497a-b6ab-c71f61b8e841.jpg?1562917793", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/b/7b761f97-3690-497a-b6ab-c71f61b8e841.jpg?1562917793"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Seismic Wave", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/5/e55b8ffb-c2e4-4676-9051-ff6c686cad0b.jpg?1654567822", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e55b8ffb-c2e4-4676-9051-ff6c686cad0b.jpg?1654567822"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Shard Volley", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/3/43db4810-078e-487a-afef-57cbc1db0cc7.jpg?1562878159", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/3/43db4810-078e-487a-afef-57cbc1db0cc7.jpg?1562878159"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/0/00365412-41db-427c-9109-8f69c17c326d.jpg?1576381909", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/0/00365412-41db-427c-9109-8f69c17c326d.jpg?1576381909"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/3/334ad39a-4088-4530-8f3c-d34e7cc99fae.jpg?1562545881", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/334ad39a-4088-4530-8f3c-d34e7cc99fae.jpg?1562545881"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/3/83c92b5d-103c-4719-a850-690a7010291a.jpg?1562926054", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/3/83c92b5d-103c-4719-a850-690a7010291a.jpg?1562926054"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/a/ea653772-a5fe-4416-bef3-fd41133371db.jpg?1562250385", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/a/ea653772-a5fe-4416-bef3-fd41133371db.jpg?1562250385"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/9/f9b2ff2a-6dfe-4635-8da2-22d525e82b94.jpg?1562597849", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/9/f9b2ff2a-6dfe-4635-8da2-22d525e82b94.jpg?1562597849"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/0/60eeb025-704c-4a82-90b2-f91202ae30d9.jpg?1623945691", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60eeb025-704c-4a82-90b2-f91202ae30d9.jpg?1623945691"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Shower of Sparks", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/4/54428999-a83d-40a5-9753-dfefdf705a9e.jpg?1562912591", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/4/54428999-a83d-40a5-9753-dfefdf705a9e.jpg?1562912591"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Shrapnel Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/5/056affab-4e2a-4b68-b864-d879becd3c45.jpg?1562134669", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/5/056affab-4e2a-4b68-b864-d879becd3c45.jpg?1562134669"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Shrapnel Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f8baa9d1-b45d-4947-9eb6-7f580c83a2c3.jpg?1562164853", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f8baa9d1-b45d-4947-9eb6-7f580c83a2c3.jpg?1562164853"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Sizzle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/f/dfdfe2a9-1323-4f15-b2ce-d8dd404b914d.jpg?1587913602", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/f/dfdfe2a9-1323-4f15-b2ce-d8dd404b914d.jpg?1587913602"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Sizzle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f1ca1eee-d97d-48c6-84f1-7d1f972c3ca9.jpg?1562383987", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f1ca1eee-d97d-48c6-84f1-7d1f972c3ca9.jpg?1562383987"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Skewer the Critics", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/7/97295660-6bea-46ae-9a3b-0fc6abba407f.jpg?1584831035", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/7/97295660-6bea-46ae-9a3b-0fc6abba407f.jpg?1584831035"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Skullcrack", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/0/8068a146-f6fe-46f3-a42e-822fbc3502e6.jpg?1561833692", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/0/8068a146-f6fe-46f3-a42e-822fbc3502e6.jpg?1561833692"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Skull Rend", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/c/1c8efb23-bac0-41d2-b4ee-27a6b1fe3134.jpg?1562783397", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/c/1c8efb23-bac0-41d2-b4ee-27a6b1fe3134.jpg?1562783397"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Skullscorch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/8/88f1343c-77bf-4f44-8226-fdfb2c2c7015.jpg?1562630775", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/8/88f1343c-77bf-4f44-8226-fdfb2c2c7015.jpg?1562630775"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Slagstorm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e318b03-2aad-462b-a2a9-8b6bdf0e93d6.jpg?1562613393", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e318b03-2aad-462b-a2a9-8b6bdf0e93d6.jpg?1562613393"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Slaying Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/3/83b5b110-c430-4ffe-9fc1-8e6987f52d1e.jpg?1572490469", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/3/83b5b110-c430-4ffe-9fc1-8e6987f52d1e.jpg?1572490469"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Smash to Smithereens", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/e/7eda1524-44dd-4f70-ac21-bac51578860e.jpg?1562832260", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/e/7eda1524-44dd-4f70-ac21-bac51578860e.jpg?1562832260"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Smash to Smithereens", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/d/4daccff6-8395-4b11-a4ce-3576aa38bc09.jpg?1562636800", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/d/4daccff6-8395-4b11-a4ce-3576aa38bc09.jpg?1562636800"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Smoke Spirits' Aid", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/4/e492a245-46ba-438e-8d81-4626faa49bff.jpg?1651655377", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/4/e492a245-46ba-438e-8d81-4626faa49bff.jpg?1651655377"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Solar Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/3/b36fc40c-6a68-4192-91d9-2031c7d32e05.jpg?1562937333", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/3/b36fc40c-6a68-4192-91d9-2031c7d32e05.jpg?1562937333"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sonic Assault", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/c/cc61a398-cf16-415b-b3cf-897217dc7cc9.jpg?1572893813", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/c/cc61a398-cf16-415b-b3cf-897217dc7cc9.jpg?1572893813"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sonic Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/5/05530d5a-dcb6-403e-9e35-224c7b5cf615.jpg?1562086889", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/5/05530d5a-dcb6-403e-9e35-224c7b5cf615.jpg?1562086889"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sonic Seizure", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/8/98eb9371-aa20-4790-baf8-a1ad95de39de.jpg?1562631090", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/8/98eb9371-aa20-4790-baf8-a1ad95de39de.jpg?1562631090"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spark Jolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6ee479c2-a115-450b-bc2e-b03d23b82f2d.jpg?1562819617", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6ee479c2-a115-450b-bc2e-b03d23b82f2d.jpg?1562819617"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spark Spray", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/6/f60d8716-4297-484c-8e02-c30ce2773a65.jpg?1562536945", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/6/f60d8716-4297-484c-8e02-c30ce2773a65.jpg?1562536945"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spawning Breath", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/0/90ec1540-e8cb-4edc-a3b3-f71423cb46fc.jpg?1562706335", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/0/90ec1540-e8cb-4edc-a3b3-f71423cb46fc.jpg?1562706335"}, "reprint": false, "digital": false, "set_type": "expansion"}]} \ No newline at end of file diff --git a/web/public/mtg/jsons/burn3.json b/web/public/mtg/jsons/burn3.json new file mode 100644 index 00000000..aa261ae5 --- /dev/null +++ b/web/public/mtg/jsons/burn3.json @@ -0,0 +1 @@ +{"has_more": false, "data": [{"name": "Staggershock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/5/75624ab3-ddbd-4fe8-8a07-7d1f78ec8a84.jpg?1562705194", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/5/75624ab3-ddbd-4fe8-8a07-7d1f78ec8a84.jpg?1562705194"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Starfall", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/3/13921f0d-f163-4275-b025-045c1ccd99e5.jpg?1593096122", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/3/13921f0d-f163-4275-b025-045c1ccd99e5.jpg?1593096122"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Start from Scratch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/5/55c99486-ae64-4293-81fb-a4b02e8fcae6.jpg?1637082383", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/5/55c99486-ae64-4293-81fb-a4b02e8fcae6.jpg?1637082383"}, "reprint": false, "frame_effects": ["lesson"], "digital": false, "set_type": "expansion"}, {"name": "Steam Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/4/144a1b4e-d960-4c3a-810b-11a0c78635ad.jpg?1562899291", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/4/144a1b4e-d960-4c3a-810b-11a0c78635ad.jpg?1562899291"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Stoke the Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/d/1d94c000-52e0-4215-83af-6351dc43e636.jpg?1562783509", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/d/1d94c000-52e0-4215-83af-6351dc43e636.jpg?1562783509"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Stoke the Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/5/f5a1071a-c50c-439f-8387-5b2c143e24e4.jpg?1562640134", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/5/f5a1071a-c50c-439f-8387-5b2c143e24e4.jpg?1562640134"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Stomping Slabs", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/2/820f1acf-7f0c-4ee5-9f18-b5627aac7c81.jpg?1562879653", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/2/820f1acf-7f0c-4ee5-9f18-b5627aac7c81.jpg?1562879653"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Strategy, Schmategy", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/2/a2996a63-9fb6-4455-906d-13f917a8bb29.jpg?1562799134", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/2/a2996a63-9fb6-4455-906d-13f917a8bb29.jpg?1562799134"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Structural Collapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/1/d10da484-db67-4afc-90ef-6caf7d2e3a75.jpg?1561847167", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/1/d10da484-db67-4afc-90ef-6caf7d2e3a75.jpg?1561847167"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Structural Distortion", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/7/a7895890-a774-4c7c-9f15-78b8aadfd9ef.jpg?1576384931", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/7/a7895890-a774-4c7c-9f15-78b8aadfd9ef.jpg?1576384931"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sudden Shock", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/f/9fcc7ad0-1348-44e9-9782-e9b7fd032fa4.jpg?1606761799", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/f/9fcc7ad0-1348-44e9-9782-e9b7fd032fa4.jpg?1606761799"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Sulfurous Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/7/67511e0e-be09-4f4e-9949-b9ecbdc7f536.jpg?1562916599", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/7/67511e0e-be09-4f4e-9949-b9ecbdc7f536.jpg?1562916599"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Surging Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/5/156994b5-a0f2-4d02-9bda-882e80d9905c.jpg?1561756701", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/5/156994b5-a0f2-4d02-9bda-882e80d9905c.jpg?1561756701"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Tarfire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/1/d13a898e-6a97-4fd9-980e-3bfd8d755386.jpg?1562369172", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/1/d13a898e-6a97-4fd9-980e-3bfd8d755386.jpg?1562369172"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thunderblade Charge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/8/88a85be1-9de5-4f96-9fd1-15f3f17c4bea.jpg?1562922621", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/8/88a85be1-9de5-4f96-9fd1-15f3f17c4bea.jpg?1562922621"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thunderbolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/8/5845a5bc-6b7d-4bbb-80b3-a0f877b95553.jpg?1592709223", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/8/5845a5bc-6b7d-4bbb-80b3-a0f877b95553.jpg?1592709223"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Thunderbolt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/0/a0a4b641-2eb3-482b-91a1-236ebe2a7a41.jpg?1562802418", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/0/a0a4b641-2eb3-482b-91a1-236ebe2a7a41.jpg?1562802418"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thunderous Wrath", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/a/daa39826-7f89-41cb-a7fe-7f7be817d5cd.jpg?1592709229", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/a/daa39826-7f89-41cb-a7fe-7f7be817d5cd.jpg?1592709229"}, "reprint": false, "frame_effects": ["miracle"], "digital": false, "set_type": "expansion"}, {"name": "Titan's Revenge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b1b0f9ca-b752-4dd6-982b-06bb3a27ddbc.jpg?1562880793", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b1b0f9ca-b752-4dd6-982b-06bb3a27ddbc.jpg?1562880793"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Touch of the Void", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/0/006ead4a-dc57-4856-8e13-235ba55483e6.jpg?1562895118", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/0/006ead4a-dc57-4856-8e13-235ba55483e6.jpg?1562895118"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Traitor's Roar", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/5/751e2700-6425-45b8-b026-8c78098f08b2.jpg?1562831801", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/5/751e2700-6425-45b8-b026-8c78098f08b2.jpg?1562831801"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Tribal Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/7/07fafa53-1e22-43f5-abf3-bbab8130f84d.jpg?1561966002", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/7/07fafa53-1e22-43f5-abf3-bbab8130f84d.jpg?1561966002"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Tribal Flames", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/b/9b32531e-c759-4603-abd0-1724e8df70db.jpg?1562926326", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/b/9b32531e-c759-4603-abd0-1724e8df70db.jpg?1562926326"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Unfriendly Fire", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/a/7a61b274-0499-4cb6-a2e4-f5e18ad7fd2d.jpg?1562558512", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/a/7a61b274-0499-4cb6-a2e4-f5e18ad7fd2d.jpg?1562558512"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Unlicensed Disintegration", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/6/16ad8f86-7860-4896-a161-07bf347bbd5b.jpg?1576382889", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/6/16ad8f86-7860-4896-a161-07bf347bbd5b.jpg?1576382889"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Unlicensed Disintegration", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/4/74843584-d6b1-4ee6-bedb-999ab0a42bb9.jpg?1562636815", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/4/74843584-d6b1-4ee6-bedb-999ab0a42bb9.jpg?1562636815"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Urza's Rage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/7/774c52e2-b0d1-4b70-b6d1-bf98f6298603.jpg?1562917055", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/7/774c52e2-b0d1-4b70-b6d1-bf98f6298603.jpg?1562917055"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Urza's Rage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/61a25a35-3ae4-471e-adcd-d8baf2f77b68.jpg?1562914759", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/61a25a35-3ae4-471e-adcd-d8baf2f77b68.jpg?1562914759"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Urza's Rage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/8/d80e9897-d84c-4992-9e8e-3a00f377c7e5.jpg?1623945800", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/8/d80e9897-d84c-4992-9e8e-3a00f377c7e5.jpg?1623945800"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Volcanic Fallout", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/5/65536d12-e75c-42b5-b592-a3ad4f550a71.jpg?1592485188", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/5/65536d12-e75c-42b5-b592-a3ad4f550a71.jpg?1592485188"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Volcanic Fallout", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/d/8d3a69d2-518d-4b70-a03e-6d02a525f9ad.jpg?1561757550", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/d/8d3a69d2-518d-4b70-a03e-6d02a525f9ad.jpg?1561757550"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Volcanic Geyser", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/f/df5bab70-3c28-48db-9ed3-64706f64f4fa.jpg?1562560984", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/f/df5bab70-3c28-48db-9ed3-64706f64f4fa.jpg?1562560984"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Volcanic Geyser", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/1/911787db-9023-46f8-9501-3ad26b6ca51d.jpg?1562720483", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/1/911787db-9023-46f8-9501-3ad26b6ca51d.jpg?1562720483"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Volcanic Hammer", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f8d93606-4864-4a5f-bcbf-8638211e979d.jpg?1562251759", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f8d93606-4864-4a5f-bcbf-8638211e979d.jpg?1562251759"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Volcanic Hammer", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/8/58c0489d-b073-4ad4-b044-447fcc865b6c.jpg?1562915903", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/8/58c0489d-b073-4ad4-b044-447fcc865b6c.jpg?1562915903"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Volcanic Hammer", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/5/9563d7c1-4ed1-4919-b0b8-ea1ec9d4bbf6.jpg?1562447337", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/5/9563d7c1-4ed1-4919-b0b8-ea1ec9d4bbf6.jpg?1562447337"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Volcanic Spray", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/7/97daab4b-d934-4a3f-a043-f7c9c1dd32bf.jpg?1562923217", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/7/97daab4b-d934-4a3f-a043-f7c9c1dd32bf.jpg?1562923217"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Volt Charge", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/a/aa88011c-a19d-4faa-8da6-86b9980cd571.jpg?1562880613", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/a/aa88011c-a19d-4faa-8da6-86b9980cd571.jpg?1562880613"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Warleader's Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/1/81e474ac-54f7-43f9-8af9-2f1adf258b15.jpg?1562919089", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/1/81e474ac-54f7-43f9-8af9-2f1adf258b15.jpg?1562919089"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Warleader's Helix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/c/fcc1dd23-90fa-4aa4-b0a9-7a92991ad7ec.jpg?1562640152", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/c/fcc1dd23-90fa-4aa4-b0a9-7a92991ad7ec.jpg?1562640152"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Weight of Spires", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/5/d5f26a87-4562-450c-800b-7d4acc1ae17b.jpg?1593273313", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/5/d5f26a87-4562-450c-800b-7d4acc1ae17b.jpg?1593273313"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Wild Slash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/9/6975490f-7679-48b3-ba34-04dec97a29c2.jpg?1562826120", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/9/6975490f-7679-48b3-ba34-04dec97a29c2.jpg?1562826120"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Winter Sky", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/f/af1035f3-3027-4a41-834c-55222b13c2bc.jpg?1562588224", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/f/af1035f3-3027-4a41-834c-55222b13c2bc.jpg?1562588224"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Wizard's Lightning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/9/59bf371a-164c-4db8-9207-197c2e7c3c10.jpg?1562736134", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/9/59bf371a-164c-4db8-9207-197c2e7c3c10.jpg?1562736134"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Word of Blasting", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/5/c5362ead-9162-4160-bfa9-432f7d0e222d.jpg?1562383027", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/5/c5362ead-9162-4160-bfa9-432f7d0e222d.jpg?1562383027"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Word of Blasting", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/6/46b383c8-d604-4131-a869-9e9d13e30b94.jpg?1562907917", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/6/46b383c8-d604-4131-a869-9e9d13e30b94.jpg?1562907917"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Yamabushi's Flame", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/a/1a9bacba-55c4-4b92-bdd9-01b6035ed1b2.jpg?1562757952", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/a/1a9bacba-55c4-4b92-bdd9-01b6035ed1b2.jpg?1562757952"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Zap", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/5/7502ce01-b762-40fe-a064-c7b20b08a722.jpg?1562918451", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/5/7502ce01-b762-40fe-a064-c7b20b08a722.jpg?1562918451"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Zenith Flare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/e/0efac1ed-3f01-487c-86be-8239568b4425.jpg?1591228324", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/e/0efac1ed-3f01-487c-86be-8239568b4425.jpg?1591228324"}, "reprint": false, "digital": false, "set_type": "expansion"}]} \ No newline at end of file diff --git a/web/public/mtg/jsons/counterspell1.json b/web/public/mtg/jsons/counterspell1.json new file mode 100644 index 00000000..abee75b2 --- /dev/null +++ b/web/public/mtg/jsons/counterspell1.json @@ -0,0 +1 @@ +{"has_more": true, "data": [{"name": "Abjure", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/b/fbad9449-d09c-4fd0-b2ad-2aa3a29e03bf.jpg?1562804357", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/b/fbad9449-d09c-4fd0-b2ad-2aa3a29e03bf.jpg?1562804357"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Absorb", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/e/1e8a43c1-42d1-45ef-8a63-4b87775a6e88.jpg?1584831352", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/e/1e8a43c1-42d1-45ef-8a63-4b87775a6e88.jpg?1584831352"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Absorb", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/d/5d6a0f3e-457f-41f5-be26-5fb249874f1a.jpg?1562913952", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/d/5d6a0f3e-457f-41f5-be26-5fb249874f1a.jpg?1562913952"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Absorb Energy", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/f/bfdca67d-9a97-4ddc-8d50-26a48ad2e4b7.jpg?1645416627", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/f/bfdca67d-9a97-4ddc-8d50-26a48ad2e4b7.jpg?1645416627"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Abstruse Interference", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/4/249a7be3-311e-4ce6-97dc-97242463ae23.jpg?1562902357", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/4/249a7be3-311e-4ce6-97dc-97242463ae23.jpg?1562902357"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Access Denied", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/6/1642df77-6fe8-47cf-b750-ca4dd9b331ba.jpg?1651655225", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/6/1642df77-6fe8-47cf-b750-ca4dd9b331ba.jpg?1651655225"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Admiral's Order", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/0/80dc0310-afd9-49b4-b58f-a0e91120c38c.jpg?1555039852", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/0/80dc0310-afd9-49b4-b58f-a0e91120c38c.jpg?1555039852"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Aether Gust", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/8/783da808-6698-4e55-9fac-430a6effe2b1.jpg?1592516251", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/8/783da808-6698-4e55-9fac-430a6effe2b1.jpg?1592516251"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Aether Gust", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bcc1aa91-ec97-4fe8-b4b1-a213f050f956.jpg?1645141636", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bcc1aa91-ec97-4fe8-b4b1-a213f050f956.jpg?1645141636"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Annul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/b/4b1d4a59-11a0-4a55-8ac0-07377a9e6dc8.jpg?1631046631", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/b/4b1d4a59-11a0-4a55-8ac0-07377a9e6dc8.jpg?1631046631"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Annul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/e/5e71b6ad-4b81-4277-8512-0a3f2266cd23.jpg?1562818788", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/e/5e71b6ad-4b81-4277-8512-0a3f2266cd23.jpg?1562818788"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Annul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/b/8ba18ec8-e82f-41be-9ed8-b1a4ae9b7426.jpg?1562150464", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/b/8ba18ec8-e82f-41be-9ed8-b1a4ae9b7426.jpg?1562150464"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Annul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/f/3f8c73ff-be92-41ca-93a7-76f9823adb38.jpg?1562908208", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/f/3f8c73ff-be92-41ca-93a7-76f9823adb38.jpg?1562908208"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "An Offer You Can't Refuse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/9/b9d349f3-5be2-4b1f-a4c3-ba94822cf0cf.jpg?1649394290", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/9/b9d349f3-5be2-4b1f-a4c3-ba94822cf0cf.jpg?1649394290"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Anticognition", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/b/db99b872-77c7-4471-9c44-a36d4ff5d33f.jpg?1604193539", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/b/db99b872-77c7-4471-9c44-a36d4ff5d33f.jpg?1604193539"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Arcane Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/d/9d1ffeb1-6c31-45f7-8140-913c397022a3.jpg?1562439019", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/d/9d1ffeb1-6c31-45f7-8140-913c397022a3.jpg?1562439019"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Arcane Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/b/ab175817-da6a-4ae7-a016-c3bfb087eae0.jpg?1562931100", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/b/ab175817-da6a-4ae7-a016-c3bfb087eae0.jpg?1562931100"}, "reprint": true, "digital": false, "set_type": "commander"}, {"name": "Arcane Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/0/b0c5728e-43e7-417a-ba18-5038345cec67.jpg?1562770144", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/0/b0c5728e-43e7-417a-ba18-5038345cec67.jpg?1562770144"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Arcane Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/1/415a3104-90e6-4235-b67f-69337c7fe714.jpg?1562768258", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/1/415a3104-90e6-4235-b67f-69337c7fe714.jpg?1562768258"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Archmage's Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/7/57b852b6-4388-4a41-a5c0-bba37a5c1451.jpg?1562201300", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/7/57b852b6-4388-4a41-a5c0-bba37a5c1451.jpg?1562201300"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Archmage's Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d378f4f8-ff9f-4389-86c8-23c5c4990b4c.jpg?1657849868", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d378f4f8-ff9f-4389-86c8-23c5c4990b4c.jpg?1657849868"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "promo"}, {"name": "Artifact Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/5/1506d99d-7b2e-4101-84a5-c950dadb263a.jpg?1562899411", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/5/1506d99d-7b2e-4101-84a5-c950dadb263a.jpg?1562899411"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Assert Authority", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/c/fc339ed7-e1d4-4fe9-a4c4-b030d3e74c00.jpg?1562163986", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/c/fc339ed7-e1d4-4fe9-a4c4-b030d3e74c00.jpg?1562163986"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Avoid Fate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/2/92f1509e-6ed5-4009-a031-ea84b43cbd1b.jpg?1562859699", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/2/92f1509e-6ed5-4009-a031-ea84b43cbd1b.jpg?1562859699"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bane's Contingency", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/9/19f81099-f657-4f7d-84ad-f472ae87d9c5.jpg?1653844052", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/9/19f81099-f657-4f7d-84ad-f472ae87d9c5.jpg?1653844052"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Bant Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/5/65b65c87-b084-44aa-b841-411a3c73e234.jpg?1562704776", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/5/65b65c87-b084-44aa-b841-411a3c73e234.jpg?1562704776"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bar the Gate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/9/b9b1e53f-1384-4860-9944-e68922afc65c.jpg?1627702860", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/9/b9b1e53f-1384-4860-9944-e68922afc65c.jpg?1627702860"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/f/cfa51783-9ef8-4e51-ba0d-ce8439d83bdf.jpg?1562936749", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/f/cfa51783-9ef8-4e51-ba0d-ce8439d83bdf.jpg?1562936749"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Bind to Secrecy", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/bab838e0-cfc5-4eeb-920d-bfbe462a1e31.jpg?1655963915", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/bab838e0-cfc5-4eeb-920d-bfbe462a1e31.jpg?1655963915"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Blue Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/f/2f51f88f-f662-4572-a371-9a77718ed079.jpg?1562434032", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/f/2f51f88f-f662-4572-a371-9a77718ed079.jpg?1562434032"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Blue Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/0/20d666ef-39bf-4fbf-8201-5f1056539da2.jpg?1559591462", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/0/20d666ef-39bf-4fbf-8201-5f1056539da2.jpg?1559591462"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Blue Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/5/6582b980-3e4b-422a-9a6c-1927ae966d7e.jpg?1561757308", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/5/6582b980-3e4b-422a-9a6c-1927ae966d7e.jpg?1561757308"}, "reprint": true, "digital": false, "set_type": "spellbook"}, {"name": "Blue Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/6/a671237a-f895-4bbc-b6bd-b0eed4502ec5.jpg?1562547160", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/6/a671237a-f895-4bbc-b6bd-b0eed4502ec5.jpg?1562547160"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Bone to Ash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/4/c4a75cef-9551-45e2-b1ff-80662c76ec20.jpg?1562941461", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/4/c4a75cef-9551-45e2-b1ff-80662c76ec20.jpg?1562941461"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Broken Ambitions", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/0/8052d90b-bc49-4a9e-9211-159a54aa2bcd.jpg?1562355294", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/0/8052d90b-bc49-4a9e-9211-159a54aa2bcd.jpg?1562355294"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Broken Concentration", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/5/252eef1f-0a62-420d-aad8-e3d7f1e07c1b.jpg?1576383988", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/5/252eef1f-0a62-420d-aad8-e3d7f1e07c1b.jpg?1576383988"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Brokers Confluence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/5/657ff5fc-1a95-46f9-85f7-fc1ad757c8c4.jpg?1650506185", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/5/657ff5fc-1a95-46f9-85f7-fc1ad757c8c4.jpg?1650506185"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Brutal Expulsion", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/c/0cd0e11a-0398-431b-b523-9d3c8a0155cb.jpg?1562132495", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/c/0cd0e11a-0398-431b-b523-9d3c8a0155cb.jpg?1562132495"}, "reprint": true, "frame_effects": ["devoid"], "digital": false, "set_type": "promo"}, {"name": "Burnout", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/a/5a8f5a18-e490-4010-ac1c-c74a5f2dcbda.jpg?1562768717", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/a/5a8f5a18-e490-4010-ac1c-c74a5f2dcbda.jpg?1562768717"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Calculated Dismissal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/c/2c42ab35-6050-42b2-9c3c-3252f2e69442.jpg?1562012331", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/c/2c42ab35-6050-42b2-9c3c-3252f2e69442.jpg?1562012331"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/f/cf6e5ad6-ffe2-4588-b357-c415c33fbc11.jpg?1562564222", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/f/cf6e5ad6-ffe2-4588-b357-c415c33fbc11.jpg?1562564222"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/2/7258e651-868a-4f63-9454-6c6c95d25387.jpg?1543674894", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/2/7258e651-868a-4f63-9454-6c6c95d25387.jpg?1543674894"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/f/9f540dcb-8d0b-4d33-8c0d-893fa5db54eb.jpg?1562791164", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/f/9f540dcb-8d0b-4d33-8c0d-893fa5db54eb.jpg?1562791164"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/d/fd994a26-65ff-43be-8d52-476e887d3ed2.jpg?1562795930", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/d/fd994a26-65ff-43be-8d52-476e887d3ed2.jpg?1562795930"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e557f54-3d9d-4610-a0d0-5874feacc76e.jpg?1562614848", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e557f54-3d9d-4610-a0d0-5874feacc76e.jpg?1562614848"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/7/479f56c2-8256-4325-909a-bf460505dbc5.jpg?1562703421", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/7/479f56c2-8256-4325-909a-bf460505dbc5.jpg?1562703421"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/4/b4e175f7-f649-451b-9ee5-ad1140b2e8a7.jpg?1562933181", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/4/b4e175f7-f649-451b-9ee5-ad1140b2e8a7.jpg?1562933181"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cancel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bc4d6368-03dc-488a-9a6b-07a549a87572.jpg?1561757939", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bc4d6368-03dc-488a-9a6b-07a549a87572.jpg?1561757939"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Censor", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4cb4e315-1a77-479a-9f15-fb23575de805.jpg?1543674908", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4cb4e315-1a77-479a-9f15-fb23575de805.jpg?1543674908"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ceremonious Rejection", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/8/08c5ed8e-4804-4042-8a1d-ad24c6846816.jpg?1576381129", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/8/08c5ed8e-4804-4042-8a1d-ad24c6846816.jpg?1576381129"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Circular Logic", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/d/cd9198d6-201d-4175-8f70-eef92d7d5bb5.jpg?1562632085", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/d/cd9198d6-201d-4175-8f70-eef92d7d5bb5.jpg?1562632085"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Clash of Wills", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/6/665ee42f-8d76-4f8b-9dd3-7455a90f0da7.jpg?1562023499", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/6/665ee42f-8d76-4f8b-9dd3-7455a90f0da7.jpg?1562023499"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Clash of Wills", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/c/1c67ab53-9489-4658-859e-9dd8a6e0f20d.jpg?1562636752", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/c/1c67ab53-9489-4658-859e-9dd8a6e0f20d.jpg?1562636752"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Complicate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/3/33f69670-e494-42b8-9148-fe105ec61aa0.jpg?1562907165", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f69670-e494-42b8-9148-fe105ec61aa0.jpg?1562907165"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Concerted Defense", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/3/235c108d-3902-4c2e-919c-a5449cd2dc3c.jpg?1604193820", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/3/235c108d-3902-4c2e-919c-a5449cd2dc3c.jpg?1604193820"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Condescend", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/b/5ba16c0f-dd42-4a2a-8f08-bc8c8478952b.jpg?1562849378", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/b/5ba16c0f-dd42-4a2a-8f08-bc8c8478952b.jpg?1562849378"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Condescend", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e8303b80-e29a-46b8-90b0-c0cfe551b435.jpg?1562880436", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e8303b80-e29a-46b8-90b0-c0cfe551b435.jpg?1562880436"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Confirm Suspicions", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/f/cf7fcbc2-1034-442d-9f2a-7d79ea40ac3d.jpg?1576384007", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/f/cf7fcbc2-1034-442d-9f2a-7d79ea40ac3d.jpg?1576384007"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Confound", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/f/4f3b7d39-ce98-48e2-b2bf-0d55b4d3102b.jpg?1562911605", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/f/4f3b7d39-ce98-48e2-b2bf-0d55b4d3102b.jpg?1562911605"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Contradict", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/0/a0b3d4ff-09d1-4d9f-8c83-cdfbd7bb1079.jpg?1562790758", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/0/a0b3d4ff-09d1-4d9f-8c83-cdfbd7bb1079.jpg?1562790758"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Controvert", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/e/0e670f6b-d16e-47fc-a5b7-7ca0d8763644.jpg?1593274904", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/e/0e670f6b-d16e-47fc-a5b7-7ca0d8763644.jpg?1593274904"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Convolute", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/f/3fd8e607-8179-4ae8-ba7f-f5f22649dc18.jpg?1591230479", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/f/3fd8e607-8179-4ae8-ba7f-f5f22649dc18.jpg?1591230479"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Convolute", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/1/e17cf756-ec41-4934-8906-4276277c1470.jpg?1576384056", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/1/e17cf756-ec41-4934-8906-4276277c1470.jpg?1576384056"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Convolute", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/a/fac88052-96a3-4a4d-95a2-c5a652fcb275.jpg?1598914075", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/a/fac88052-96a3-4a4d-95a2-c5a652fcb275.jpg?1598914075"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Corrupted Resolve", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/8/28432161-023b-4a98-b92a-55dc6d936cd1.jpg?1562876198", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/8/28432161-023b-4a98-b92a-55dc6d936cd1.jpg?1562876198"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Counterbore", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/4/f4228b80-d87d-4ebe-ae92-04e4a7d0dc43.jpg?1562838120", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/4/f4228b80-d87d-4ebe-ae92-04e4a7d0dc43.jpg?1562838120"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Counterflux", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/4/94e4b773-40a4-4272-85dd-f728ada22748.jpg?1562790128", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/4/94e4b773-40a4-4272-85dd-f728ada22748.jpg?1562790128"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Counterflux", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e864fd80-baee-468e-9dc3-e650cc203b23.jpg?1657120160", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e864fd80-baee-468e-9dc3-e650cc203b23.jpg?1657120160"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Counterlash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d3ec2c57-8e67-472d-8f2e-0492d311f130.jpg?1562945498", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d3ec2c57-8e67-472d-8f2e-0492d311f130.jpg?1562945498"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Countermand", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/7/07815e32-0b64-4c2b-84e6-a72336c45cf5.jpg?1593095401", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/7/07815e32-0b64-4c2b-84e6-a72336c45cf5.jpg?1593095401"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/c/0c9a7cb0-5bff-48ff-b620-2838816ac9b5.jpg?1580013910", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/c/0c9a7cb0-5bff-48ff-b620-2838816ac9b5.jpg?1580013910"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/9/29bb1b85-9444-4bfa-b622-092a6873631c.jpg?1562234566", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/9/29bb1b85-9444-4bfa-b622-092a6873631c.jpg?1562234566"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/b/7bd03c80-7812-4704-9e07-9cf73b49c01f.jpg?1562381815", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/b/7bd03c80-7812-4704-9e07-9cf73b49c01f.jpg?1562381815"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/a/dacdd380-71cf-4832-bd02-3697501325f3.jpg?1562056885", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/a/dacdd380-71cf-4832-bd02-3697501325f3.jpg?1562056885"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/9/b975289d-d8b8-46b4-8c60-d6ed4b594519.jpg?1562593755", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/9/b975289d-d8b8-46b4-8c60-d6ed4b594519.jpg?1562593755"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/aedbcbaa-40f0-485f-8427-778edc2d2ec0.jpg?1562927522", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/aedbcbaa-40f0-485f-8427-778edc2d2ec0.jpg?1562927522"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0df55e3f-14de-46ef-b6b1-616618724d9e.jpg?1559591713", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0df55e3f-14de-46ef-b6b1-616618724d9e.jpg?1559591713"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/3/f35ec9da-f38b-4b7f-9eb5-090ca7755668.jpg?1645141660", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/3/f35ec9da-f38b-4b7f-9eb5-090ca7755668.jpg?1645141660"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/c/2c358d75-01ad-4487-8104-425124b96aae.jpg?1628337127", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/c/2c358d75-01ad-4487-8104-425124b96aae.jpg?1628337127"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "draft_innovation"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/f/ffdf9d2a-c163-43df-9a2f-20b8749c86ae.jpg?1631491044", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/f/ffdf9d2a-c163-43df-9a2f-20b8749c86ae.jpg?1631491044"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/1/3126d20f-1082-4ebc-b2fa-b12be3ba1bac.jpg?1562904991", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/1/3126d20f-1082-4ebc-b2fa-b12be3ba1bac.jpg?1562904991"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Counterspell", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/0/7065deea-6117-47d4-9d72-fc67af5bb483.jpg?1561757383", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/0/7065deea-6117-47d4-9d72-fc67af5bb483.jpg?1561757383"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Countersquall", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/b/2b645d74-420e-45e5-aa82-ba3a8dfdd9a0.jpg?1562905206", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/b/2b645d74-420e-45e5-aa82-ba3a8dfdd9a0.jpg?1562905206"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Countersquall", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/c/ec16e216-95e1-41f7-87e0-78b6ac3fe1df.jpg?1562804491", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/c/ec16e216-95e1-41f7-87e0-78b6ac3fe1df.jpg?1562804491"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Countervailing Winds", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/de1c0ef3-b32c-403a-93cb-29cf05795711.jpg?1562817497", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/de1c0ef3-b32c-403a-93cb-29cf05795711.jpg?1562817497"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Crush Dissent", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/4/94c70f23-0ca9-425e-a53a-6c09921c0075.jpg?1557576187", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/4/94c70f23-0ca9-425e-a53a-6c09921c0075.jpg?1557576187"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cryptic Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/0/30f6fca9-003b-4f6b-9d6e-1e88adda4155.jpg?1562847413", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/0/30f6fca9-003b-4f6b-9d6e-1e88adda4155.jpg?1562847413"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Cryptic Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/2/829e3d6e-5d7c-4cc4-a7a6-7cbf5a7442ba.jpg?1562355759", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/2/829e3d6e-5d7c-4cc4-a7a6-7cbf5a7442ba.jpg?1562355759"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Cryptic Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/2/a2a384c1-a05f-4f00-bd77-f897d9819971.jpg?1562927862", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/2/a2a384c1-a05f-4f00-bd77-f897d9819971.jpg?1562927862"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Cryptic Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/2/526607e9-1907-4639-b944-8ee152c81bfb.jpg?1561757137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/2/526607e9-1907-4639-b944-8ee152c81bfb.jpg?1561757137"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Dash Hopes", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/1/814bcfc0-7539-4ed9-8b51-27e6a3ab9d9a.jpg?1562575740", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/1/814bcfc0-7539-4ed9-8b51-27e6a3ab9d9a.jpg?1562575740"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dawn Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/4/a4c9667b-1d94-42eb-ae8e-1ae4755e200a.jpg?1562578420", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/4/a4c9667b-1d94-42eb-ae8e-1ae4755e200a.jpg?1562578420"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Daze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/0/f05e9a3e-8a35-4687-85cb-e31b3927a5e2.jpg?1580013916", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/0/f05e9a3e-8a35-4687-85cb-e31b3927a5e2.jpg?1580013916"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Daze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/0/d03bff25-0d5e-4dcf-8d75-6df846afea3b.jpg?1562632115", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/0/d03bff25-0d5e-4dcf-8d75-6df846afea3b.jpg?1562632115"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Daze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/9/a9b037f1-3298-4ba8-92a8-0843f6e497d7.jpg?1562929191", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/9/a9b037f1-3298-4ba8-92a8-0843f6e497d7.jpg?1562929191"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Decisive Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/2/b2e9d132-95f7-4ee7-9c91-be19e4ad7a5d.jpg?1627428577", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/2/b2e9d132-95f7-4ee7-9c91-be19e4ad7a5d.jpg?1627428577"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Delay", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/9/3906d538-f1ca-4799-b91c-2e0d2934f241.jpg?1619393997", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/9/3906d538-f1ca-4799-b91c-2e0d2934f241.jpg?1619393997"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Delay", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e821d337-4bc5-4401-ac9b-34adf4012b73.jpg?1562941573", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e821d337-4bc5-4401-ac9b-34adf4012b73.jpg?1562941573"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Denied!", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/2/1285c125-e145-4565-a029-352ac6adf688.jpg?1562799062", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/2/1285c125-e145-4565-a029-352ac6adf688.jpg?1562799062"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Deny Existence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/6/16a14eeb-1c85-4029-a047-39a4efef3f74.jpg?1576384025", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/6/16a14eeb-1c85-4029-a047-39a4efef3f74.jpg?1576384025"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Deny the Divine", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/2/1200f68a-a8ea-4777-b6b0-de48b2203fd1.jpg?1588900840", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/2/1200f68a-a8ea-4777-b6b0-de48b2203fd1.jpg?1588900840"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Deprive", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/e/2efecdd9-bd3a-4b79-92da-6485589d5bde.jpg?1562702470", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/e/2efecdd9-bd3a-4b79-92da-6485589d5bde.jpg?1562702470"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Desertion", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/a/9a2a1779-af08-4a9a-aba4-e6892ce2332c.jpg?1562278155", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/a/9a2a1779-af08-4a9a-aba4-e6892ce2332c.jpg?1562278155"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Devious Cover-Up", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/648281fe-89fb-4d8d-b944-3af28fb044f6.jpg?1634348751", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/648281fe-89fb-4d8d-b944-3af28fb044f6.jpg?1634348751"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Devious Cover-Up", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/1/21ac6b0a-b1a5-439d-b65e-5f04e1826c80.jpg?1636491628", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/1/21ac6b0a-b1a5-439d-b65e-5f04e1826c80.jpg?1636491628"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Didn't Say Please", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/7/77500b53-0852-4d6a-bfe3-b1e8ef5a12cd.jpg?1572489858", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/7/77500b53-0852-4d6a-bfe3-b1e8ef5a12cd.jpg?1572489858"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dimir Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/3/f3f4cfa7-8ee4-4a85-9e6a-65a7541f62c1.jpg?1561852231", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/3/f3f4cfa7-8ee4-4a85-9e6a-65a7541f62c1.jpg?1561852231"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dimir Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/f/9f6bc1da-3969-4f19-b072-4ed79f906fef.jpg?1562497257", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/f/9f6bc1da-3969-4f19-b072-4ed79f906fef.jpg?1562497257"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Disallow", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/5/25f05814-a5a5-460f-9d29-0ab03efecf4c.jpg?1576381471", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/5/25f05814-a5a5-460f-9d29-0ab03efecf4c.jpg?1576381471"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disappearing Act", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/a/9a4a6d56-9bed-444c-aae8-383c315779a0.jpg?1576381158", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/a/9a4a6d56-9bed-444c-aae8-383c315779a0.jpg?1576381158"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Discombobulate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/e/cef584c5-6e2d-419b-9c11-a1b6c9c9ab2a.jpg?1562943839", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/e/cef584c5-6e2d-419b-9c11-a1b6c9c9ab2a.jpg?1562943839"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Discontinuity", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/3/b33ba0a8-04e9-4df6-af20-a3ca4470cdcc.jpg?1594735451", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/3/b33ba0a8-04e9-4df6-af20-a3ca4470cdcc.jpg?1594735451"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Disdainful Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/9/492aa24c-61c4-48bc-b7b7-f423be2662da.jpg?1649881231", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/9/492aa24c-61c4-48bc-b7b7-f423be2662da.jpg?1649881231"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Disdainful Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/6/7691ac89-f8ba-493e-aa11-5674a783dffb.jpg?1631047007", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/6/7691ac89-f8ba-493e-aa11-5674a783dffb.jpg?1631047007"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Disdainful Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/1/0193dfa3-8409-44be-b4be-6c3cad42d4a4.jpg?1572892724", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/1/0193dfa3-8409-44be-b4be-6c3cad42d4a4.jpg?1572892724"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Disdainful Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/8/180425c9-1898-48d4-9932-ddfb1a28e6b0.jpg?1562783110", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/8/180425c9-1898-48d4-9932-ddfb1a28e6b0.jpg?1562783110"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disdainful Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/7/3711f61d-6381-4c92-a3f5-6deed29aae47.jpg?1562639749", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/7/3711f61d-6381-4c92-a3f5-6deed29aae47.jpg?1562639749"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Dismal Failure", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/5/35786a7a-faa6-457d-9b92-da560b93a43a.jpg?1562569290", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/5/35786a7a-faa6-457d-9b92-da560b93a43a.jpg?1562569290"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dismiss", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/e/1e55d6be-7682-4786-9872-e847afd710b0.jpg?1562052798", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/e/1e55d6be-7682-4786-9872-e847afd710b0.jpg?1562052798"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dispel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bceab6b3-6b64-4964-a501-ce806a6c13ad.jpg?1562939587", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bceab6b3-6b64-4964-a501-ce806a6c13ad.jpg?1562939587"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Dispel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/8/08d4a8d7-c136-472f-8146-a1100701ca4f.jpg?1562782227", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/8/08d4a8d7-c136-472f-8146-a1100701ca4f.jpg?1562782227"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Dispel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f178d0cc-5dd1-41ab-a2e8-218ece6f2a86.jpg?1562299138", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f178d0cc-5dd1-41ab-a2e8-218ece6f2a86.jpg?1562299138"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dispersal Shield", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/c/0c257df6-f275-40db-bfe3-a9291356cdf7.jpg?1562525399", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/c/0c257df6-f275-40db-bfe3-a9291356cdf7.jpg?1562525399"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disrupt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c000a02f-6b7e-4925-a938-59e645e980d7.jpg?1562933600", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c000a02f-6b7e-4925-a938-59e645e980d7.jpg?1562933600"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Disrupt", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/6/c6cc89b0-9acf-452b-ac1a-bc7e90eb32fc.jpg?1562803281", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/6/c6cc89b0-9acf-452b-ac1a-bc7e90eb32fc.jpg?1562803281"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disrupting Shoal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/5/15589745-4c0a-4edf-ad45-3b7fa45e70c5.jpg?1562875608", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/5/15589745-4c0a-4edf-ad45-3b7fa45e70c5.jpg?1562875608"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Disruption Protocol", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/5/053ab598-06a4-43ae-b9fd-c291bd05642c.jpg?1654566666", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/5/053ab598-06a4-43ae-b9fd-c291bd05642c.jpg?1654566666"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dissipate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/6/4689b3f2-e4b7-448e-b3d4-ab33194aafb2.jpg?1634348774", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/6/4689b3f2-e4b7-448e-b3d4-ab33194aafb2.jpg?1634348774"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Dissipate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/d/5d778082-bcdb-423a-b16f-57ac0d4dace7.jpg?1562830916", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/d/5d778082-bcdb-423a-b16f-57ac0d4dace7.jpg?1562830916"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Dissipate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/6/36d9271d-6dbf-4640-9222-721a7a3ccc08.jpg?1562718782", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/6/36d9271d-6dbf-4640-9222-721a7a3ccc08.jpg?1562718782"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dissolve", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/9/992e8119-f933-4e54-bb04-e1cc78f7e87b.jpg?1562821811", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/9/992e8119-f933-4e54-bb04-e1cc78f7e87b.jpg?1562821811"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dissolve", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f2068083-5d53-43c3-af22-79bf617ccf1b.jpg?1562640127", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f2068083-5d53-43c3-af22-79bf617ccf1b.jpg?1562640127"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Divide by Zero", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/9/1958d96e-ec44-48ab-80b1-5b01a24ac7b8.jpg?1644607565", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/9/1958d96e-ec44-48ab-80b1-5b01a24ac7b8.jpg?1644607565"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Double Negative", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/c/2c7e3c58-3cda-4891-8b3d-33bb21568cf5.jpg?1562640325", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/c/2c7e3c58-3cda-4891-8b3d-33bb21568cf5.jpg?1562640325"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dovin's Veto", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/6/d60ca98f-7f91-4bbd-9d06-dadb0c1da282.jpg?1570573658", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/6/d60ca98f-7f91-4bbd-9d06-dadb0c1da282.jpg?1570573658"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "promo"}, {"name": "Dream Fracture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/a/daca6a57-38b7-4547-9174-a7f548ea1258.jpg?1653691053", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/a/daca6a57-38b7-4547-9174-a7f548ea1258.jpg?1653691053"}, "reprint": true, "digital": false, "set_type": "draft_innovation"}, {"name": "Dream Fracture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4cfd71ff-d899-4f5b-b7df-ec47e2840be9.jpg?1562911180", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4cfd71ff-d899-4f5b-b7df-ec47e2840be9.jpg?1562911180"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Dromar's Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/9/69f752d3-3f42-4275-be09-d257c89da70d.jpg?1562917160", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/9/69f752d3-3f42-4275-be09-d257c89da70d.jpg?1562917160"}, "reprint": true, "digital": false, "set_type": "commander"}, {"name": "Dromar's Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/7/c7a1894c-af4e-4530-960f-2225916be8cb.jpg?1562937176", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/7/c7a1894c-af4e-4530-960f-2225916be8cb.jpg?1562937176"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Drown in the Loch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/b/8bf5df5b-164d-4ec2-a5e6-bbaea152e271.jpg?1572490739", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/b/8bf5df5b-164d-4ec2-a5e6-bbaea152e271.jpg?1572490739"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Drown in the Loch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/1/01acd1c1-86b2-4423-9ba7-5b9725c0514f.jpg?1640249448", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/1/01acd1c1-86b2-4423-9ba7-5b9725c0514f.jpg?1640249448"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Endless Detour", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/3/13798c8c-1aa5-4f95-979b-b971e73d715f.jpg?1649942599", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/3/13798c8c-1aa5-4f95-979b-b971e73d715f.jpg?1649942599"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Endless Detour", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/5/e55503d2-1b32-43cf-95c6-a4a61047a4dc.jpg?1649942620", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e55503d2-1b32-43cf-95c6-a4a61047a4dc.jpg?1649942620"}, "reprint": false, "frame_effects": ["showcase"], "digital": false, "set_type": "expansion"}, {"name": "Envelop", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/7/e7ed250e-12d0-4ebc-9410-5711e71c6d1f.jpg?1562632433", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/7/e7ed250e-12d0-4ebc-9410-5711e71c6d1f.jpg?1562632433"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ertai's Meddling", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/5/35c7e7fa-1493-4ef8-9cdb-b02b07a1ad85.jpg?1562053736", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/5/35c7e7fa-1493-4ef8-9cdb-b02b07a1ad85.jpg?1562053736"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ertai's Trickery", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/4/544e3575-9fb6-41f7-a4e6-f8460dfae344.jpg?1562912607", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/4/544e3575-9fb6-41f7-a4e6-f8460dfae344.jpg?1562912607"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Essence Backlash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/9/a98609dc-ea90-4c7e-a191-5e5d0ba16847.jpg?1562791298", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/9/a98609dc-ea90-4c7e-a191-5e5d0ba16847.jpg?1562791298"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Essence Capture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/3/f39bf1fa-b530-4353-a683-843466227109.jpg?1654566672", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/3/f39bf1fa-b530-4353-a683-843466227109.jpg?1654566672"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Essence Capture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/e/ce137910-0f0e-4f94-9b95-6e0eeeba164e.jpg?1584830187", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/e/ce137910-0f0e-4f94-9b95-6e0eeeba164e.jpg?1584830187"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Essence Scatter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/f/5f79c8a0-291e-4e13-b765-4cf8c726cf30.jpg?1636491405", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/f/5f79c8a0-291e-4e13-b765-4cf8c726cf30.jpg?1636491405"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Essence Scatter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/1/e1e325e1-f1f9-4448-84e3-1fd929b0bc12.jpg?1543674950", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/1/e1e325e1-f1f9-4448-84e3-1fd929b0bc12.jpg?1543674950"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Essence Scatter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/2/c231101e-6620-46fc-a0ad-a53291d12dc2.jpg?1561994248", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/2/c231101e-6620-46fc-a0ad-a53291d12dc2.jpg?1561994248"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Evasive Action", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/d/5d0b4f29-ada4-41d2-8292-b5af537c6fd2.jpg?1562916923", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/d/5d0b4f29-ada4-41d2-8292-b5af537c6fd2.jpg?1562916923"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Exclude", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/1/a1a50f54-6363-41dd-88a7-9f9e820e7d5f.jpg?1562439432", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/1/a1a50f54-6363-41dd-88a7-9f9e820e7d5f.jpg?1562439432"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Exclude", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/7/970864e0-5488-4b6f-9316-3e3b4098770e.jpg?1561951119", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/7/970864e0-5488-4b6f-9316-3e3b4098770e.jpg?1561951119"}, "reprint": true, "digital": false, "set_type": "commander"}, {"name": "Exclude", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/aeb359c8-209c-455f-84b2-970e5678a9fa.jpg?1562930137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/aeb359c8-209c-455f-84b2-970e5678a9fa.jpg?1562930137"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Extinguish", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/1/21140417-09f5-4d05-b94c-355fde9b4719.jpg?1562255853", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/1/21140417-09f5-4d05-b94c-355fde9b4719.jpg?1562255853"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Extinguish", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/641f4e66-b46b-4da3-a053-f3763400d4f5.jpg?1562918557", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/641f4e66-b46b-4da3-a053-f3763400d4f5.jpg?1562918557"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Faerie Trickery", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/defb9f0b-195e-4aeb-92c1-8f827ad6724b.jpg?1562371108", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/defb9f0b-195e-4aeb-92c1-8f827ad6724b.jpg?1562371108"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Failed Inspection", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f8900f91-cb17-4f99-a5ce-15819369beb8.jpg?1576381199", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f8900f91-cb17-4f99-a5ce-15819369beb8.jpg?1576381199"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fall of the Gavel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/64f42848-963b-4b16-aeec-66d0f349758b.jpg?1562787318", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/64f42848-963b-4b16-aeec-66d0f349758b.jpg?1562787318"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "False Summoning", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/d/cd7d30a8-bc7a-42bc-8d1b-600cbf78ab98.jpg?1562943500", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/d/cd7d30a8-bc7a-42bc-8d1b-600cbf78ab98.jpg?1562943500"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Familiar's Ruse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/5/55b9be91-f3a1-49ce-8a3e-2ecd30e2e692.jpg?1562348978", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/5/55b9be91-f3a1-49ce-8a3e-2ecd30e2e692.jpg?1562348978"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fervent Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/b/7b15428e-946e-490d-93bb-9888bfd3a1df.jpg?1568003997", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/b/7b15428e-946e-490d-93bb-9888bfd3a1df.jpg?1568003997"}, "reprint": true, "digital": false, "set_type": "commander"}, {"name": "Fervent Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/d/ed13fdb4-f28a-43c9-a69f-bab227806c39.jpg?1562939482", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/d/ed13fdb4-f28a-43c9-a69f-bab227806c39.jpg?1562939482"}, "reprint": false, "frame_effects": ["tombstone"], "digital": false, "set_type": "expansion"}, {"name": "Fierce Guardianship", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4c5ffa83-c88d-4f5d-851e-a642b229d596.jpg?1591319453", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4c5ffa83-c88d-4f5d-851e-a642b229d596.jpg?1591319453"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Flaccify", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/0/409cb48a-572a-40df-ae1a-a43feab6bdfd.jpg?1562487932", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/0/409cb48a-572a-40df-ae1a-a43feab6bdfd.jpg?1562487932"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Flash Counter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/c/dc14e61f-481a-4bfa-aca0-fb63dc952be6.jpg?1562939250", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/c/dc14e61f-481a-4bfa-aca0-fb63dc952be6.jpg?1562939250"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Flash Counter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/c/3c3cd450-f1cd-416b-9271-37d95815c089.jpg?1587858200", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/c/3c3cd450-f1cd-416b-9271-37d95815c089.jpg?1587858200"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flashfreeze", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/e/cefd9955-a195-4855-a00e-3809b96ca92b.jpg?1593274923", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/e/cefd9955-a195-4855-a00e-3809b96ca92b.jpg?1593274923"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flip the Switch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/c/5cdbe4e3-f030-46fa-ae84-edf261b61706.jpg?1634348893", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/c/5cdbe4e3-f030-46fa-ae84-edf261b61706.jpg?1634348893"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Flusterstorm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/e/1e2e09bf-e7c8-4f13-bcee-f9c8cbc57993.jpg?1592713006", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/e/1e2e09bf-e7c8-4f13-bcee-f9c8cbc57993.jpg?1592713006"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Flusterstorm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/c/9c2077c2-81ce-4ddf-82f0-6fece362d6d7.jpg?1562546827", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/c/9c2077c2-81ce-4ddf-82f0-6fece362d6d7.jpg?1562546827"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Foil", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e8b39fd6-9240-4f76-b12c-e7d9aa88f061.jpg?1547516254", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e8b39fd6-9240-4f76-b12c-e7d9aa88f061.jpg?1547516254"}, "reprint": true, "digital": false, "set_type": "masters"}]} \ No newline at end of file diff --git a/web/public/mtg/jsons/counterspell2.json b/web/public/mtg/jsons/counterspell2.json new file mode 100644 index 00000000..e32ed2c7 --- /dev/null +++ b/web/public/mtg/jsons/counterspell2.json @@ -0,0 +1 @@ +{"has_more": true, "data": [{"name": "Foil", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/7/870fb793-3107-4cb2-ba78-34fbf5c9da2f.jpg?1562920018", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/7/870fb793-3107-4cb2-ba78-34fbf5c9da2f.jpg?1562920018"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fold into Aether", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/615157d6-0160-417b-b06c-0e253b306c37.jpg?1562877336", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/615157d6-0160-417b-b06c-0e253b306c37.jpg?1562877336"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Forbid", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/9/29df5ef7-d679-4543-bdb7-3984155c87e0.jpg?1562087370", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/9/29df5ef7-d679-4543-bdb7-3984155c87e0.jpg?1562087370"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Forbid", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/4/14a9cc52-a45b-4cde-8aff-d672b35c3118.jpg?1562899128", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/4/14a9cc52-a45b-4cde-8aff-d672b35c3118.jpg?1562899128"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Forceful Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/7/27c75157-2670-4804-8853-a6867c83c40a.jpg?1608909212", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/7/27c75157-2670-4804-8853-a6867c83c40a.jpg?1608909212"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Force of Negation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/9/e9be371c-c688-44ad-ab71-bd4c9f242d58.jpg?1562201382", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/9/e9be371c-c688-44ad-ab71-bd4c9f242d58.jpg?1562201382"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Force of Negation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/3/5396b405-6fa0-43d7-a8f6-f64154e95e98.jpg?1655823932", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/3/5396b405-6fa0-43d7-a8f6-f64154e95e98.jpg?1655823932"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "masters"}, {"name": "Force of Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/b/ebc01ab4-d89a-4d25-bf54-6aed33772f4b.jpg?1580013954", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/b/ebc01ab4-d89a-4d25-bf54-6aed33772f4b.jpg?1580013954"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Force of Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/a/9a879b60-4381-447d-8a5a-8e0b6a1d49ca.jpg?1562769672", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/a/9a879b60-4381-447d-8a5a-8e0b6a1d49ca.jpg?1562769672"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Force of Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/c/ec136ce7-bad4-4ebb-ab00-b86de3d209a7.jpg?1599710933", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/c/ec136ce7-bad4-4ebb-ab00-b86de3d209a7.jpg?1599710933"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Force of Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/2/026983a4-03ca-4812-b129-5ea523596942.jpg?1562895460", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/2/026983a4-03ca-4812-b129-5ea523596942.jpg?1562895460"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Force of Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/3/53ed5673-728f-4da3-ad18-3bd72032e815.jpg?1562258455", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/3/53ed5673-728f-4da3-ad18-3bd72032e815.jpg?1562258455"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Force Spike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/d/1d03d73f-0530-4125-8689-1c43e502e331.jpg?1562233829", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/d/1d03d73f-0530-4125-8689-1c43e502e331.jpg?1562233829"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Force Spike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba23d540-8c2d-4a42-b4c0-86f0988bd1ce.jpg?1562593757", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba23d540-8c2d-4a42-b4c0-86f0988bd1ce.jpg?1562593757"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Force Spike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/0/70e64028-ae96-4950-aa6c-9d347409fad3.jpg?1562859654", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/0/70e64028-ae96-4950-aa6c-9d347409fad3.jpg?1562859654"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Force Void", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/226555ba-22af-45f1-a3f4-d265f8685dd5.jpg?1587911634", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/226555ba-22af-45f1-a3f4-d265f8685dd5.jpg?1587911634"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Frazzle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/8/68b7f705-4d64-4551-8d76-826d91324e9e.jpg?1593271993", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/8/68b7f705-4d64-4551-8d76-826d91324e9e.jpg?1593271993"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Frightful Delusion", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/8/38c9ba98-90b4-4c28-9eef-a4fe0913b921.jpg?1562828708", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/8/38c9ba98-90b4-4c28-9eef-a4fe0913b921.jpg?1562828708"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Fuel for the Cause", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/1/4126e0e5-9b23-496f-8a09-7a35499f9a09.jpg?1562610827", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/1/4126e0e5-9b23-496f-8a09-7a35499f9a09.jpg?1562610827"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Gainsay", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e658939a-fa5a-4497-b35c-b6fbfa3f6882.jpg?1562835545", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e658939a-fa5a-4497-b35c-b6fbfa3f6882.jpg?1562835545"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Gainsay", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/7/a70a2092-5048-49c0-9351-a3f882c2f56e.jpg?1562930170", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/7/a70a2092-5048-49c0-9351-a3f882c2f56e.jpg?1562930170"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Gale's Redirection", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/f/1f5ddcf8-c87b-4a26-b226-8593f517a74a.jpg?1653353395", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/f/1f5ddcf8-c87b-4a26-b226-8593f517a74a.jpg?1653353395"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Geistlight Snare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/3/7302b5da-cac5-4ce7-ad38-2ff4e410891b.jpg?1643587841", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/3/7302b5da-cac5-4ce7-ad38-2ff4e410891b.jpg?1643587841"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Geist Snatch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/6/b6dac5db-ef96-4bd5-aabc-e5ae2b95c8c3.jpg?1592708554", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/6/b6dac5db-ef96-4bd5-aabc-e5ae2b95c8c3.jpg?1592708554"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Glorious End", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/9/2922b976-7beb-4c68-b39e-1b66d5c6f65e.jpg?1543675588", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/9/2922b976-7beb-4c68-b39e-1b66d5c6f65e.jpg?1543675588"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Grip of Amnesia", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/3/43dc7e2a-5b9b-4f0f-8b2e-a7c7f847e1f1.jpg?1562629609", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/3/43dc7e2a-5b9b-4f0f-8b2e-a7c7f847e1f1.jpg?1562629609"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Guttural Response", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/1/9121e55e-5070-48cc-b706-92c67ad89254.jpg?1592761849", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/1/9121e55e-5070-48cc-b706-92c67ad89254.jpg?1592761849"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Guttural Response", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/0/e0662ab6-b475-4b8d-ae77-a9b654e611da.jpg?1562837134", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/0/e0662ab6-b475-4b8d-ae77-a9b654e611da.jpg?1562837134"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Halt Order", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/f/7fed18af-7301-4d03-ba7c-e94f07f078b3.jpg?1562819574", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/f/7fed18af-7301-4d03-ba7c-e94f07f078b3.jpg?1562819574"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hinder", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/c/dc7befed-805b-4a02-a87d-7df3a95db8a0.jpg?1562765119", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/c/dc7befed-805b-4a02-a87d-7df3a95db8a0.jpg?1562765119"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hinder", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/7/679d6226-7ec1-44f3-ac90-30b123501aa0.jpg?1561757329", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/7/679d6226-7ec1-44f3-ac90-30b123501aa0.jpg?1561757329"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Hindering Light", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/8/98e43870-4bed-4d76-a633-a6326c736d22.jpg?1562706936", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/8/98e43870-4bed-4d76-a633-a6326c736d22.jpg?1562706936"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hindering Touch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/b/db9735d9-4aac-4175-8ec8-fc9bfd8f2c5c.jpg?1562535667", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/b/db9735d9-4aac-4175-8ec8-fc9bfd8f2c5c.jpg?1562535667"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hisoka's Defiance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/9/09fd4d01-1204-46a3-b237-45c37985acac.jpg?1562757466", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/9/09fd4d01-1204-46a3-b237-45c37985acac.jpg?1562757466"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hornswoggle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/1/b10b8f15-b323-44d8-85a7-ed662a40889d.jpg?1555039907", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/1/b10b8f15-b323-44d8-85a7-ed662a40889d.jpg?1555039907"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Horribly Awry", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4cd05532-686e-40dc-858b-8a77a3628c99.jpg?1562912968", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4cd05532-686e-40dc-858b-8a77a3628c99.jpg?1562912968"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Hydroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4c9c9b16-5567-4473-95e6-622292f77336.jpg?1580013995", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4c9c9b16-5567-4473-95e6-622292f77336.jpg?1580013995"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Hydroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/6/f62716f0-fde2-49ef-b8a4-c1b03f451194.jpg?1562941220", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/6/f62716f0-fde2-49ef-b8a4-c1b03f451194.jpg?1562941220"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Hydroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/222db3a6-c2b1-48fc-9b0c-018ac6ed517b.jpg?1562543501", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/222db3a6-c2b1-48fc-9b0c-018ac6ed517b.jpg?1562543501"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Illumination", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/b/eb28f6e5-c9ef-416e-b315-967d857e7600.jpg?1562722393", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/b/eb28f6e5-c9ef-416e-b315-967d857e7600.jpg?1562722393"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Induce Paranoia", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bc462b75-8b08-47a3-be22-d7b5c062ec5b.jpg?1598914307", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bc462b75-8b08-47a3-be22-d7b5c062ec5b.jpg?1598914307"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Insidious Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/e/8eafb2bb-58bf-4c6b-ae8f-91bcea12c7d2.jpg?1576381260", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/e/8eafb2bb-58bf-4c6b-ae8f-91bcea12c7d2.jpg?1576381260"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Interdict", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/4/3442c919-73b9-4d29-a014-87293f456325.jpg?1562053290", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/4/3442c919-73b9-4d29-a014-87293f456325.jpg?1562053290"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Intervene", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/b/4b0e3894-5dfe-4d03-9996-eebf96c58168.jpg?1562862808", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/b/4b0e3894-5dfe-4d03-9996-eebf96c58168.jpg?1562862808"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Invasive Surgery", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6e644e38-39bf-40bd-9be1-5eb80f472e81.jpg?1576384110", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6e644e38-39bf-40bd-9be1-5eb80f472e81.jpg?1576384110"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ionize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f161f7d2-eaa1-4931-93f9-befa8b5df821.jpg?1572893679", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f161f7d2-eaa1-4931-93f9-befa8b5df821.jpg?1572893679"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ixidor's Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1b713448-853a-41ee-a302-963e9c1c1c65.jpg?1562901464", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1b713448-853a-41ee-a302-963e9c1c1c65.jpg?1562901464"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Izzet Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/61289196-a56b-4d24-b340-9cf067c77f45.jpg?1592713417", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/61289196-a56b-4d24-b340-9cf067c77f45.jpg?1592713417"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Izzet Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/8/e8e84a97-8e40-42fa-a114-df90e820ede6.jpg?1562497263", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/8/e8e84a97-8e40-42fa-a114-df90e820ede6.jpg?1562497263"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Jace's Defeat", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/6/c6b103c1-9b25-4bfe-9081-570977e9fdad.jpg?1562814148", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/6/c6b103c1-9b25-4bfe-9081-570977e9fdad.jpg?1562814148"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Jaded Response", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/a/6a9ab1f0-4e75-4165-85bc-6f838c221d6a.jpg?1562920093", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/a/6a9ab1f0-4e75-4165-85bc-6f838c221d6a.jpg?1562920093"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Keep Safe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/e/febfa682-76ae-4979-a40c-c1eae1121f3c.jpg?1591226372", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/e/febfa682-76ae-4979-a40c-c1eae1121f3c.jpg?1591226372"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Kindred Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/f/4fbdeac6-f61b-4669-934c-9216d669500f.jpg?1645417342", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/f/4fbdeac6-f61b-4669-934c-9216d669500f.jpg?1645417342"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Lapse of Certainty", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/c/ec609036-dfbf-47de-9a3a-762aea4196d4.jpg?1562804498", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/c/ec609036-dfbf-47de-9a3a-762aea4196d4.jpg?1562804498"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Laquatus's Disdain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/2/e2ea5448-2d72-42eb-814c-197153d8e06a.jpg?1562632366", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/2/e2ea5448-2d72-42eb-814c-197153d8e06a.jpg?1562632366"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Last Word", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/3/139d2ece-f656-4cac-8d77-b0f083f76c70.jpg?1562635496", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/3/139d2ece-f656-4cac-8d77-b0f083f76c70.jpg?1562635496"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lay Bare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/4/0454c2a8-b17d-4cdf-8562-9a28bc6cf0be.jpg?1562700738", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/4/0454c2a8-b17d-4cdf-8562-9a28bc6cf0be.jpg?1562700738"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Liquify", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/2/12fadf25-0995-440d-a3e6-7964ed86cff6.jpg?1562628664", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/2/12fadf25-0995-440d-a3e6-7964ed86cff6.jpg?1562628664"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lofty Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/4/64832674-beb1-446e-b2f7-8a5e271139a5.jpg?1616182218", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/4/64832674-beb1-446e-b2f7-8a5e271139a5.jpg?1616182218"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Logic Knot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/2/624feb0e-f683-4eb6-a63b-7872d0e28f1f.jpg?1619394325", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/2/624feb0e-f683-4eb6-a63b-7872d0e28f1f.jpg?1619394325"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Logic Knot", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/e/4e946be1-4ed6-4e2c-9782-3f630f8a8e1f.jpg?1562910897", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/e/4e946be1-4ed6-4e2c-9782-3f630f8a8e1f.jpg?1562910897"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lookout's Dispersal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/5/f5751a3c-7695-4c47-9cbd-92fd5b1b7ec9.jpg?1562566719", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/5/f5751a3c-7695-4c47-9cbd-92fd5b1b7ec9.jpg?1562566719"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Lose Focus", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/8/985bdb0c-ce6c-4506-8163-76f3b2fdf5fb.jpg?1626094565", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/8/985bdb0c-ce6c-4506-8163-76f3b2fdf5fb.jpg?1626094565"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Lost in the Mist", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/e/1e5fc39d-590a-436b-ab90-a1741d2ae3da.jpg?1562827161", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/e/1e5fc39d-590a-436b-ab90-a1741d2ae3da.jpg?1562827161"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mages' Contest", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/5/c516861c-68d9-4d02-a343-689dba0526c6.jpg?1562934507", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/5/c516861c-68d9-4d02-a343-689dba0526c6.jpg?1562934507"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Make Disappear", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/f/3f2d6a21-ea77-484b-9e3a-1bd49806f907.jpg?1649471769", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/f/3f2d6a21-ea77-484b-9e3a-1bd49806f907.jpg?1649471769"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mana Drain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/1/416d2d51-8f29-4e95-b037-e8c32b081e6c.jpg?1562848002", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/1/416d2d51-8f29-4e95-b037-e8c32b081e6c.jpg?1562848002"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Mana Drain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/c/cc9a04dc-afee-4194-80f5-fb1d9c906de7.jpg?1562936126", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/c/cc9a04dc-afee-4194-80f5-fb1d9c906de7.jpg?1562936126"}, "reprint": true, "digital": true, "set_type": "masters"}, {"name": "Mana Drain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e691adef-3027-4e6a-889f-9f4e2df36a7c.jpg?1562861377", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e691adef-3027-4e6a-889f-9f4e2df36a7c.jpg?1562861377"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mana Drain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/5/456a2f03-8304-4512-804c-76653e30f436.jpg?1655827521", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/5/456a2f03-8304-4512-804c-76653e30f436.jpg?1655827521"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "masters"}, {"name": "Mana Leak", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/7/a7c7757d-8036-4b33-a1cb-07795d392588.jpg?1562470857", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/7/a7c7757d-8036-4b33-a1cb-07795d392588.jpg?1562470857"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Mana Leak", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/b/abcaf16d-aa02-43e2-aa38-bb1835d47a05.jpg?1562597349", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/b/abcaf16d-aa02-43e2-aa38-bb1835d47a05.jpg?1562597349"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mana Leak", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/e/dea41eb7-5828-4735-bca1-0dbb0fda04e3.jpg?1561758236", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/e/dea41eb7-5828-4735-bca1-0dbb0fda04e3.jpg?1561758236"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Mana Tithe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/d/7d48d622-f397-4f31-b1a5-0c23f60aa71c.jpg?1562575298", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/d/7d48d622-f397-4f31-b1a5-0c23f60aa71c.jpg?1562575298"}, "reprint": false, "frame_effects": ["colorshifted"], "digital": false, "set_type": "expansion"}, {"name": "Mana Tithe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/7/e7f32354-893d-4f0b-b555-e0757fb5443b.jpg?1623592291", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/7/e7f32354-893d-4f0b-b555-e0757fb5443b.jpg?1623592291"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Mana Tithe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/5/652b0ce3-293d-4599-8a04-9df01b9bc678.jpg?1561757305", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/5/652b0ce3-293d-4599-8a04-9df01b9bc678.jpg?1561757305"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Memory Drain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/a/aadc1809-d6bb-455c-b6ce-dd11521808b6.jpg?1581479403", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/a/aadc1809-d6bb-455c-b6ce-dd11521808b6.jpg?1581479403"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/0/30202613-d05f-4f47-af97-d0b75ccac293.jpg?1634131658", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/0/30202613-d05f-4f47-af97-d0b75ccac293.jpg?1634131658"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/d/2d85cc30-ccae-4af8-834a-f7870dace679.jpg?1562235009", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/d/2d85cc30-ccae-4af8-834a-f7870dace679.jpg?1562235009"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/3/63453ed9-5cf1-4cad-b173-a067f22a4405.jpg?1562719747", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/3/63453ed9-5cf1-4cad-b173-a067f22a4405.jpg?1562719747"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/d/3d2cc591-3a81-468a-91a4-3c3aac83a21a.jpg?1562587259", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/d/3d2cc591-3a81-468a-91a4-3c3aac83a21a.jpg?1562587259"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/c/6c8b5df3-6153-470e-be9c-f38d3cf66081.jpg?1562587296", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/c/6c8b5df3-6153-470e-be9c-f38d3cf66081.jpg?1562587296"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Memory Lapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/8/98c1b465-b6d9-491b-bfc2-c034cc825d27.jpg?1623592117", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/8/98c1b465-b6d9-491b-bfc2-c034cc825d27.jpg?1623592117"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Mental Misstep", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/1/61e9c6df-1c84-4eab-9076-a4feb6347c10.jpg?1566819829", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/1/61e9c6df-1c84-4eab-9076-a4feb6347c10.jpg?1566819829"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Metallic Rebuke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/7/f712ac26-dca4-459b-84c1-010597007f60.jpg?1576381519", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/7/f712ac26-dca4-459b-84c1-010597007f60.jpg?1576381519"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Minamo's Meddling", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/0/502c4aca-98f8-4c7d-89fd-ee42c938fac7.jpg?1562876978", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/0/502c4aca-98f8-4c7d-89fd-ee42c938fac7.jpg?1562876978"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mindbreak Trap", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/f/4f51140b-6254-431a-8810-94307bfdfbbe.jpg?1562612097", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/f/4f51140b-6254-431a-8810-94307bfdfbbe.jpg?1562612097"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mindstatic", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/5/55d3fad5-a12a-4b41-9c7b-c1af5e0b5ca8.jpg?1562910742", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/5/55d3fad5-a12a-4b41-9c7b-c1af5e0b5ca8.jpg?1562910742"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mindswipe", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/5/557e8303-a021-4257-b41a-7d25f04618c8.jpg?1562786781", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/5/557e8303-a021-4257-b41a-7d25f04618c8.jpg?1562786781"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Miscalculation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/b/4b4956a2-9a39-4152-9c98-70e4b2acfa26.jpg?1562862809", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/b/4b4956a2-9a39-4152-9c98-70e4b2acfa26.jpg?1562862809"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Miscast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/3/033afbd5-9937-4957-98ba-48e469a490bb.jpg?1594735579", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/033afbd5-9937-4957-98ba-48e469a490bb.jpg?1594735579"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Molten Influence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4c2b326b-d177-4a03-a0a3-fe2c2d4af272.jpg?1562908953", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4c2b326b-d177-4a03-a0a3-fe2c2d4af272.jpg?1562908953"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Muddle the Mixture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/c/4cc785b0-0a77-4b02-b0b4-2bda2fc621cc.jpg?1598914378", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/c/4cc785b0-0a77-4b02-b0b4-2bda2fc621cc.jpg?1598914378"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mystical Dispute", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/b/fbe04cb8-a8b9-4241-baae-b398a2509a3a.jpg?1572489956", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/b/fbe04cb8-a8b9-4241-baae-b398a2509a3a.jpg?1572489956"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Mystic Confluence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/1/81bbffc2-6f58-4baa-8f95-168eab106b15.jpg?1562706477", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/1/81bbffc2-6f58-4baa-8f95-168eab106b15.jpg?1562706477"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Mystic Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/2/1296ddc4-300d-44f6-95d8-1b392613d379.jpg?1562255840", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/2/1296ddc4-300d-44f6-95d8-1b392613d379.jpg?1562255840"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Mystic Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/0/30bb424f-f3d6-4616-a368-df12af3ad024.jpg?1562906405", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/0/30bb424f-f3d6-4616-a368-df12af3ad024.jpg?1562906405"}, "reprint": true, "digital": false, "set_type": "starter"}, {"name": "Mystic Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/2/52d60f29-6da0-4ce6-9c92-96f313007271.jpg?1562446637", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/2/52d60f29-6da0-4ce6-9c92-96f313007271.jpg?1562446637"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Mystic Genesis", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/e/ae1dd1ac-1a1e-485d-a11f-d1323a69f95e.jpg?1561841867", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/e/ae1dd1ac-1a1e-485d-a11f-d1323a69f95e.jpg?1561841867"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Narset's Reversal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/3/63754036-d51e-47bb-925b-564d9dc922ff.jpg?1557576279", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/3/63754036-d51e-47bb-925b-564d9dc922ff.jpg?1557576279"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/9/e92c7477-d453-4fa4-acf4-3835ab9eb55a.jpg?1604194548", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/9/e92c7477-d453-4fa4-acf4-3835ab9eb55a.jpg?1604194548"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/1/31534f45-43e6-4103-bf58-ad8fa688e4b0.jpg?1555039942", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/1/31534f45-43e6-4103-bf58-ad8fa688e4b0.jpg?1555039942"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/b/cb142515-0856-441d-84d4-9c9d450a86e9.jpg?1576381530", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/b/cb142515-0856-441d-84d4-9c9d450a86e9.jpg?1576381530"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/2/026c499d-3d5b-4f65-a824-f78f146b82ef.jpg?1562895467", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/2/026c499d-3d5b-4f65-a824-f78f146b82ef.jpg?1562895467"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/0/60380ed0-fed1-4d68-9763-56a9ff8ac5e6.jpg?1562787156", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60380ed0-fed1-4d68-9763-56a9ff8ac5e6.jpg?1562787156"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/a/5a501252-e722-4ebf-bcf7-f53a42745fa7.jpg?1562878670", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/a/5a501252-e722-4ebf-bcf7-f53a42745fa7.jpg?1562878670"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/b/5bfe3a17-3349-4fcc-a9b5-418faa55cc43.jpg?1623592516", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/b/5bfe3a17-3349-4fcc-a9b5-418faa55cc43.jpg?1623592516"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/8/9850fbe9-68d2-4952-b48d-4737cef34f4a.jpg?1561757632", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/8/9850fbe9-68d2-4952-b48d-4737cef34f4a.jpg?1561757632"}, "reprint": true, "digital": false, "set_type": "spellbook"}, {"name": "Negate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/226e5187-d285-4547-869d-761fdbee6f1b.jpg?1561756781", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/226e5187-d285-4547-869d-761fdbee6f1b.jpg?1561756781"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Neutralize", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/4/0430da3c-9460-4b62-ae28-2e7e6f4d06a4.jpg?1591226400", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/4/0430da3c-9460-4b62-ae28-2e7e6f4d06a4.jpg?1591226400"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Neutralizing Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/5/e549a8fc-6001-43db-88b1-ce8ed42a3443.jpg?1562830917", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e549a8fc-6001-43db-88b1-ce8ed42a3443.jpg?1562830917"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Nix", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/d/3dab4f64-2a91-409a-b83b-45b22afd22ff.jpg?1562907421", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/d/3dab4f64-2a91-409a-b83b-45b22afd22ff.jpg?1562907421"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "No Escape", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bc9888a1-6f35-4802-b8fb-902017736d4a.jpg?1557576285", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bc9888a1-6f35-4802-b8fb-902017736d4a.jpg?1557576285"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Not of This World", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/6/569e2c39-7a49-4a3b-afe5-1862a7da8026.jpg?1562704022", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/6/569e2c39-7a49-4a3b-afe5-1862a7da8026.jpg?1562704022"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Nullify", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/9/a940d859-3fb1-4946-8277-b7c503605b1e.jpg?1593091715", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/9/a940d859-3fb1-4946-8277-b7c503605b1e.jpg?1593091715"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Obscura Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/9/9961562d-cad9-40e5-afae-3ebce77a2260.jpg?1648583418", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/9/9961562d-cad9-40e5-afae-3ebce77a2260.jpg?1648583418"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Obscura Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/a/4a02b758-65b6-4c25-83b9-de63a1a92b51.jpg?1648583494", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/a/4a02b758-65b6-4c25-83b9-de63a1a92b51.jpg?1648583494"}, "reprint": false, "frame_effects": ["showcase"], "digital": false, "set_type": "expansion"}, {"name": "Offering to Asha", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/6/260fe443-ca03-42b1-bcee-86e5173c1aaf.jpg?1562640177", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/6/260fe443-ca03-42b1-bcee-86e5173c1aaf.jpg?1562640177"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ojutai's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/7/c7a7f500-594d-4c7b-80e8-54ae1ada2444.jpg?1562792959", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/7/c7a7f500-594d-4c7b-80e8-54ae1ada2444.jpg?1562792959"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Ojutai's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/3/939778a2-a10d-4dd4-8f78-0c366b76bf81.jpg?1562876267", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/3/939778a2-a10d-4dd4-8f78-0c366b76bf81.jpg?1562876267"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Oppressive Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/b/abcb5e75-c7a1-41de-a952-05aefb115270.jpg?1562495576", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/b/abcb5e75-c7a1-41de-a952-05aefb115270.jpg?1562495576"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Out of Bounds", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/b/8b457672-902b-42c0-9d53-a3c21be2f500.jpg?1562923137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/b/8b457672-902b-42c0-9d53-a3c21be2f500.jpg?1562923137"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Outwit", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/2/429f7cf0-579a-4003-b5cf-4baf5d420796.jpg?1592708662", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/2/429f7cf0-579a-4003-b5cf-4baf5d420796.jpg?1592708662"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Override", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/5/35964fa6-800d-41d6-9f82-fb9c87deee56.jpg?1562140248", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/5/35964fa6-800d-41d6-9f82-fb9c87deee56.jpg?1562140248"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Overrule", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/22b83a31-f974-4a49-b9ee-92f7767f11e0.jpg?1593273676", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/22b83a31-f974-4a49-b9ee-92f7767f11e0.jpg?1593273676"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Overwhelming Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/3/33ff1000-1a4e-43f6-aa02-1dbe9fac6901.jpg?1562905471", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33ff1000-1a4e-43f6-aa02-1dbe9fac6901.jpg?1562905471"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Overwhelming Intellect", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/b/cbeea686-7efc-48f5-b90b-bf1befc76a30.jpg?1562496066", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/b/cbeea686-7efc-48f5-b90b-bf1befc76a30.jpg?1562496066"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pact of Negation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/c/cca467a2-a2b3-4bdf-9d60-62979f675347.jpg?1562936138", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/c/cca467a2-a2b3-4bdf-9d60-62979f675347.jpg?1562936138"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pact of Negation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/a/3ab90299-547a-4538-a31c-f55afab10c50.jpg?1562906886", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/a/3ab90299-547a-4538-a31c-f55afab10c50.jpg?1562906886"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Perplex", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0db57459-29f0-4ef6-b256-56955036c0ef.jpg?1598917204", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0db57459-29f0-4ef6-b256-56955036c0ef.jpg?1598917204"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Plasm Capture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/f/0ffe8485-d5fb-47cc-af53-6e0fd062b7a2.jpg?1562898119", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/f/0ffe8485-d5fb-47cc-af53-6e0fd062b7a2.jpg?1562898119"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Power Sink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/6/662cf693-18c4-4169-bcce-09862778f60c.jpg?1562916378", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/6/662cf693-18c4-4169-bcce-09862778f60c.jpg?1562916378"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Power Sink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/b/abc58c34-c3de-47f8-a42f-3a974dcb9c47.jpg?1562055922", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/b/abc58c34-c3de-47f8-a42f-3a974dcb9c47.jpg?1562055922"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Power Sink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/9/49717583-e0bb-47d6-92d0-8959af13391f.jpg?1562718814", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/9/49717583-e0bb-47d6-92d0-8959af13391f.jpg?1562718814"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Power Sink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/5/85cbec45-81b4-40cc-b356-d6713a6a9b2b.jpg?1562919825", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/5/85cbec45-81b4-40cc-b356-d6713a6a9b2b.jpg?1562919825"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Power Sink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/b/1b342dd3-09b9-4108-bf12-a65d4cef4eb9.jpg?1559591331", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/b/1b342dd3-09b9-4108-bf12-a65d4cef4eb9.jpg?1559591331"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Preemptive Strike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/2/c2314bf1-b22d-48c2-860f-e1081f56296b.jpg?1562257530", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/2/c2314bf1-b22d-48c2-860f-e1081f56296b.jpg?1562257530"}, "reprint": false, "digital": false, "set_type": "starter"}, {"name": "Prohibit", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0daa5458-2a97-40d0-b18d-2381a7a68ee1.jpg?1562897807", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0daa5458-2a97-40d0-b18d-2381a7a68ee1.jpg?1562897807"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Psychic Barrier", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/c/1cba7d67-5c6c-4738-8907-7cce503e3180.jpg?1562875859", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/c/1cba7d67-5c6c-4738-8907-7cce503e3180.jpg?1562875859"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Psychic Rebuttal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/7/67a105f8-0c01-4c09-a3bf-8c912b6dc741.jpg?1562023585", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/7/67a105f8-0c01-4c09-a3bf-8c912b6dc741.jpg?1562023585"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Psychic Strike", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0d87927c-80a6-4146-92a5-58c510ce7958.jpg?1561815780", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0d87927c-80a6-4146-92a5-58c510ce7958.jpg?1561815780"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Psychic Trance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/5/d5e55695-16cc-4373-8078-959f1ded4c6d.jpg?1562945989", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/5/d5e55695-16cc-4373-8078-959f1ded4c6d.jpg?1562945989"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Punish Ignorance", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/b/9bc37d01-ffe5-4dfe-b59e-204df82d1d36.jpg?1562707043", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/b/9bc37d01-ffe5-4dfe-b59e-204df82d1d36.jpg?1562707043"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Put Away", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/1/c17dff9e-23f7-4b12-95e7-aa1c00ab3d18.jpg?1562835533", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/1/c17dff9e-23f7-4b12-95e7-aa1c00ab3d18.jpg?1562835533"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pyroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/0/b029eb9a-dd7a-40c2-96c4-0063d9cc002c.jpg?1580014621", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/0/b029eb9a-dd7a-40c2-96c4-0063d9cc002c.jpg?1580014621"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Pyroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/3/c342cac5-08ae-4428-9c2c-f6c5904e54d2.jpg?1562931528", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/3/c342cac5-08ae-4428-9c2c-f6c5904e54d2.jpg?1562931528"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Pyroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/3/93c460dc-cef2-4345-b9b8-a774307ba2d6.jpg?1593559584", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/3/93c460dc-cef2-4345-b9b8-a774307ba2d6.jpg?1593559584"}, "reprint": true, "digital": false, "set_type": "spellbook"}, {"name": "Pyroblast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/3/33afbf78-7a50-48e0-bec8-656f571759e2.jpg?1562543945", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33afbf78-7a50-48e0-bec8-656f571759e2.jpg?1562543945"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Quandrix Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/2/021b62d8-d160-47f5-bc51-0474f160d13f.jpg?1624739521", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/2/021b62d8-d160-47f5-bc51-0474f160d13f.jpg?1624739521"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Quash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/8/48ca8c31-a9ea-4388-b257-951c1c68b86d.jpg?1562876834", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/8/48ca8c31-a9ea-4388-b257-951c1c68b86d.jpg?1562876834"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Quash", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/2/62019ac4-a5a1-4a8c-bfb4-96e818949bbe.jpg?1562444219", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/2/62019ac4-a5a1-4a8c-bfb4-96e818949bbe.jpg?1562444219"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Quench", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/e/ee0ba01b-de96-4f8f-9405-ff3ad288afac.jpg?1589832153", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/e/ee0ba01b-de96-4f8f-9405-ff3ad288afac.jpg?1589832153"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rakshasa's Disdain", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/d/bd9e8a25-2e71-431b-897f-8b62520a3ce9.jpg?1562829343", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/d/bd9e8a25-2e71-431b-897f-8b62520a3ce9.jpg?1562829343"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rebuff the Wicked", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/a/fa47fcce-d4c4-40a2-8853-6d7569d50926.jpg?1562586538", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/a/fa47fcce-d4c4-40a2-8853-6d7569d50926.jpg?1562586538"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Red Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/0/70a45e9b-699e-425a-9f3d-267274830d3e.jpg?1562436618", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/0/70a45e9b-699e-425a-9f3d-267274830d3e.jpg?1562436618"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Red Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/7/776ad9be-3309-4f1d-9f27-6219d9477662.jpg?1559591383", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/7/776ad9be-3309-4f1d-9f27-6219d9477662.jpg?1559591383"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Red Elemental Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/c/6cdd2a7c-001d-4891-8513-4b6d96968b35.jpg?1562545467", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/c/6cdd2a7c-001d-4891-8513-4b6d96968b35.jpg?1562545467"}, "reprint": true, "digital": true, "set_type": "promo"}, {"name": "Reinterpret", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/6/765e64ae-699c-46bd-a8cc-c8c1075d644f.jpg?1625192562", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/6/765e64ae-699c-46bd-a8cc-c8c1075d644f.jpg?1625192562"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Reject", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/7/d77f0731-fb40-4dc2-8530-afcb5ce1f27f.jpg?1624661968", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/7/d77f0731-fb40-4dc2-8530-afcb5ce1f27f.jpg?1624661968"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Remand", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/0/0027e5ca-8046-40a0-bd73-79be55f28bff.jpg?1592754515", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/0/0027e5ca-8046-40a0-bd73-79be55f28bff.jpg?1592754515"}, "reprint": true, "digital": false, "set_type": "duel_deck"}, {"name": "Remand", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/8/581f3780-c480-48c6-b15c-1618f2feccb9.jpg?1598914434", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/8/581f3780-c480-48c6-b15c-1618f2feccb9.jpg?1598914434"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Remand", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/4/d41e8cc0-4e05-412b-8ea3-d5b5c45da601.jpg?1562164467", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/4/d41e8cc0-4e05-412b-8ea3-d5b5c45da601.jpg?1562164467"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Remove Soul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f25f4f0e-bbf4-46b1-97fd-e796ff9e138f.jpg?1562251278", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f25f4f0e-bbf4-46b1-97fd-e796ff9e138f.jpg?1562251278"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Remove Soul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/d/fd6bbb81-b830-4b22-be9a-852d9edbda21.jpg?1562595434", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/d/fd6bbb81-b830-4b22-be9a-852d9edbda21.jpg?1562595434"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Remove Soul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/3/63de147c-2e62-41b9-8ada-93406387f08b.jpg?1562859196", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/3/63de147c-2e62-41b9-8ada-93406387f08b.jpg?1562859196"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Remove Soul", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/7/675440ff-9701-4310-a4ad-8502b9cb73ae.jpg?1561757323", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/7/675440ff-9701-4310-a4ad-8502b9cb73ae.jpg?1561757323"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Render Silent", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/3/e3f3d6e4-0abe-4042-a7f6-0395683e8582.jpg?1562937631", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/3/e3f3d6e4-0abe-4042-a7f6-0395683e8582.jpg?1562937631"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Render Silent", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/5/4514a13f-5eee-49a8-876c-6b4befff4592.jpg?1561757030", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/5/4514a13f-5eee-49a8-876c-6b4befff4592.jpg?1561757030"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Repel Intruders", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/8/38e64b09-1a58-4669-b7f2-baa3ccc85f2d.jpg?1568911006", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/8/38e64b09-1a58-4669-b7f2-baa3ccc85f2d.jpg?1568911006"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rethink", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/1/915ae03f-22f3-4ecc-a875-5226d8dec384.jpg?1562921984", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/1/915ae03f-22f3-4ecc-a875-5226d8dec384.jpg?1562921984"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Revolutionary Rebuff", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6ea63dad-6afe-464e-ab19-fabd9709c6f9.jpg?1576381387", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6ea63dad-6afe-464e-ab19-fabd9709c6f9.jpg?1576381387"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rewind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e51c4fb-fb29-4b1c-b78e-1fadf94fc9a5.jpg?1562928379", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e51c4fb-fb29-4b1c-b78e-1fadf94fc9a5.jpg?1562928379"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rites of Refusal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/a/fa88f595-1b6f-4af0-bc50-bd07c8be431f.jpg?1562942139", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/a/fa88f595-1b6f-4af0-bc50-bd07c8be431f.jpg?1562942139"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Runeboggle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/7/37b2fb23-f8b5-4f83-9b29-b18507acaa1a.jpg?1593272065", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/7/37b2fb23-f8b5-4f83-9b29-b18507acaa1a.jpg?1593272065"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Rune Snag", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/5/45b6cadf-1974-47c8-98d8-ba413486c3b5.jpg?1593275010", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/5/45b6cadf-1974-47c8-98d8-ba413486c3b5.jpg?1593275010"}, "reprint": false, "digital": false, "set_type": "expansion"}]} \ No newline at end of file diff --git a/web/public/mtg/jsons/counterspell3.json b/web/public/mtg/jsons/counterspell3.json new file mode 100644 index 00000000..97aeadc5 --- /dev/null +++ b/web/public/mtg/jsons/counterspell3.json @@ -0,0 +1 @@ +{"has_more": false, "data": [{"name": "Rust", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/d/ad4974c8-34c5-4290-b325-7586a67f6d56.jpg?1592364545", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/d/ad4974c8-34c5-4290-b325-7586a67f6d56.jpg?1592364545"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sage's Dousing", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/5/75ccd5f6-b363-433f-9e98-f65e10b10bc9.jpg?1562879335", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/5/75ccd5f6-b363-433f-9e98-f65e10b10bc9.jpg?1562879335"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Saw It Coming", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/7/877a1bb9-5eae-453a-bec0-a9de20ea6815.jpg?1631047574", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/7/877a1bb9-5eae-453a-bec0-a9de20ea6815.jpg?1631047574"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scatter Arc", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/2/32ed969f-2c8e-4421-9448-dc5a2afdc81d.jpg?1561821983", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/2/32ed969f-2c8e-4421-9448-dc5a2afdc81d.jpg?1561821983"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scattering Stroke", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/5/c536c1ce-a012-4d77-ab29-8574be164731.jpg?1562367009", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/5/c536c1ce-a012-4d77-ab29-8574be164731.jpg?1562367009"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scatter to the Winds", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/7/d73ad49f-fe15-4fe5-9731-fd71d31c1e7f.jpg?1562946348", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/7/d73ad49f-fe15-4fe5-9731-fd71d31c1e7f.jpg?1562946348"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Scent of Brine", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/1/d117bf8d-23ec-4f9d-99d0-3a990c5f7075.jpg?1562445215", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/1/d117bf8d-23ec-4f9d-99d0-3a990c5f7075.jpg?1562445215"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Second Guess", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/d/0d22d093-8e89-4d54-ac04-14c8759de3ea.jpg?1592708686", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/d/0d22d093-8e89-4d54-ac04-14c8759de3ea.jpg?1592708686"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Silumgar's Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba26dbbc-d4a2-44a1-8e6b-affe61f43a34.jpg?1562792137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba26dbbc-d4a2-44a1-8e6b-affe61f43a34.jpg?1562792137"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Silumgar's Scorn", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/7/077bee72-62f6-4d90-8557-ff9cac42ec9a.jpg?1562782102", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/7/077bee72-62f6-4d90-8557-ff9cac42ec9a.jpg?1562782102"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sinister Sabotage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/c/6cbef36d-7170-424f-8fb1-8e7e112b7f0b.jpg?1572892841", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/c/6cbef36d-7170-424f-8fb1-8e7e112b7f0b.jpg?1572892841"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Soul Manipulation", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/c/bcd3cb05-c6f9-435a-a0e7-1f85da4a36eb.jpg?1562643969", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/c/bcd3cb05-c6f9-435a-a0e7-1f85da4a36eb.jpg?1562643969"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/2/42d7af6a-bfd1-4e89-965a-68336507a9ee.jpg?1562828463", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/2/42d7af6a-bfd1-4e89-965a-68336507a9ee.jpg?1562828463"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Spell Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/f/5fe58a24-f6a6-4858-82a5-0ca1d524efe1.jpg?1562054243", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/f/5fe58a24-f6a6-4858-82a5-0ca1d524efe1.jpg?1562054243"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Spell Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/0/70e4584f-6e44-4ff8-8313-c8791e0156af.jpg?1562591827", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/0/70e4584f-6e44-4ff8-8313-c8791e0156af.jpg?1562591827"}, "reprint": true, "digital": false, "set_type": "core"}, {"name": "Spell Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/4/845734da-ab03-4dbc-bb5f-96481d3b8e88.jpg?1559591342", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/4/845734da-ab03-4dbc-bb5f-96481d3b8e88.jpg?1559591342"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Spell Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/1/8169929c-641f-41c8-a48e-1a7d0c57726b.jpg?1619394723", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/1/8169929c-641f-41c8-a48e-1a7d0c57726b.jpg?1619394723"}, "reprint": true, "digital": false, "set_type": "masters"}, {"name": "Spell Burst", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/9/f95c8015-fd7d-4329-ab23-aec37a824083.jpg?1562947751", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/9/f95c8015-fd7d-4329-ab23-aec37a824083.jpg?1562947751"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Contortion", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/b/4b748d8b-898f-4b55-bc33-f5bbbc823c45.jpg?1562286779", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/b/4b748d8b-898f-4b55-bc33-f5bbbc823c45.jpg?1562286779"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Counter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/3/e3d323f0-334f-49d1-b338-24c4b854a112.jpg?1562489832", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/3/e3d323f0-334f-49d1-b338-24c4b854a112.jpg?1562489832"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Spell Crumple", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/2/2247df4a-c5d8-4b34-b3a6-3c958eb65f94.jpg?1592713127", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/2/2247df4a-c5d8-4b34-b3a6-3c958eb65f94.jpg?1592713127"}, "reprint": false, "digital": false, "set_type": "commander"}, {"name": "Spelljack", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/e/3eda8c7b-ce35-482a-bece-52a30cc78a9a.jpg?1562629500", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/e/3eda8c7b-ce35-482a-bece-52a30cc78a9a.jpg?1562629500"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/e/beb42273-935b-4bda-849e-c163606cf89e.jpg?1654566963", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/e/beb42273-935b-4bda-849e-c163606cf89e.jpg?1654566963"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/b/6bf4dfc0-c58b-4535-b660-54ceaa6e0217.jpg?1562557054", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/b/6bf4dfc0-c58b-4535-b660-54ceaa6e0217.jpg?1562557054"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/b/cb3d3901-e4a6-45ab-a7b5-c65d91e1875e.jpg?1562616640", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/b/cb3d3901-e4a6-45ab-a7b5-c65d91e1875e.jpg?1562616640"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/3/d3c8f1c8-2b57-41a3-abeb-77ac7de62fa1.jpg?1656006437", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/3/d3c8f1c8-2b57-41a3-abeb-77ac7de62fa1.jpg?1656006437"}, "reprint": true, "frame_effects": ["inverted"], "digital": false, "set_type": "masters"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/4/a4f8b11a-6b21-4532-96c9-bdb2cad603e8.jpg?1599332212", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/4/a4f8b11a-6b21-4532-96c9-bdb2cad603e8.jpg?1599332212"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Spell Pierce", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/e/eef1f68a-b27c-4e81-9a3c-dccb86771bec.jpg?1562942998", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/e/eef1f68a-b27c-4e81-9a3c-dccb86771bec.jpg?1562942998"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Spell Rupture", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/2/7267fcec-0879-4743-a45f-35057ccb2596.jpg?1561831328", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/2/7267fcec-0879-4743-a45f-35057ccb2596.jpg?1561831328"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spellshift", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/5/f5c897a6-5835-42ac-8cc7-e8d9fc1e7c77.jpg?1562586074", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/5/f5c897a6-5835-42ac-8cc7-e8d9fc1e7c77.jpg?1562586074"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Shrivel", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/f/efa110cb-f091-48f0-bc62-80f5f18568e8.jpg?1562951938", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/f/efa110cb-f091-48f0-bc62-80f5f18568e8.jpg?1562951938"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Spell Snare", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/5/35554fdf-c70a-4baa-a35a-414caa9978be.jpg?1593272766", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/5/35554fdf-c70a-4baa-a35a-414caa9978be.jpg?1593272766"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Snip", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/6/d6870203-ece9-4fe0-912b-2dcf685f3eb0.jpg?1562709543", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/6/d6870203-ece9-4fe0-912b-2dcf685f3eb0.jpg?1562709543"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Snuff", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/f/efadce19-07f4-47af-abc0-a436bafcdd65.jpg?1562201508", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/f/efadce19-07f4-47af-abc0-a436bafcdd65.jpg?1562201508"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Spell Suck", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/6/f631bd92-2046-468d-8b10-d583a318ed24.jpg?1562946926", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/6/f631bd92-2046-468d-8b10-d583a318ed24.jpg?1562946926"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Spell Swindle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6e619ada-e9ce-4758-afd8-8def853877eb.jpg?1562557238", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6e619ada-e9ce-4758-afd8-8def853877eb.jpg?1562557238"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Spell Syphon", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/8/b883113c-e52b-4633-b4a4-016093327b6a.jpg?1562835117", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/8/b883113c-e52b-4633-b4a4-016093327b6a.jpg?1562835117"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Split Decision", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/3/83ed7ebe-48be-4e6e-a293-b81484f85142.jpg?1562865914", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/3/83ed7ebe-48be-4e6e-a293-b81484f85142.jpg?1562865914"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Squelch", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/9/29421dd2-70a7-4623-afe0-ca4cb415ec87.jpg?1562758853", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/9/29421dd2-70a7-4623-afe0-ca4cb415ec87.jpg?1562758853"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Statute of Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/f/af13770d-dddb-4b78-9cd3-4a0dc50472f4.jpg?1562792750", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/f/af13770d-dddb-4b78-9cd3-4a0dc50472f4.jpg?1562792750"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Steel Sabotage", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/b/bb40de7c-1905-4615-844b-4abc231fb01e.jpg?1562614249", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/b/bb40de7c-1905-4615-844b-4abc231fb01e.jpg?1562614249"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Stifle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/d/2d7643c0-b2db-478f-944e-b27b77bad3eb.jpg?1562527068", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/d/2d7643c0-b2db-478f-944e-b27b77bad3eb.jpg?1562527068"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Stifle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/a/ea24228f-da16-46eb-9dcf-a377286b6168.jpg?1562942013", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/a/ea24228f-da16-46eb-9dcf-a377286b6168.jpg?1562942013"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Stifle", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/6/c6228e16-72d4-4771-9e3f-a83ec856d315.jpg?1562636845", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/6/c6228e16-72d4-4771-9e3f-a83ec856d315.jpg?1562636845"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Stoic Rebuttal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f2805239-f30a-4eca-a10b-41673daaa287.jpg?1562825062", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f2805239-f30a-4eca-a10b-41673daaa287.jpg?1562825062"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Stubborn Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/f/6f8626c4-306f-4e9d-8840-2bb73fe87e87.jpg?1562788344", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/f/6f8626c4-306f-4e9d-8840-2bb73fe87e87.jpg?1562788344"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Stymied Hopes", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/7/5702b757-5be5-4a48-bc73-a87ec4f3193b.jpg?1562818334", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/7/5702b757-5be5-4a48-bc73-a87ec4f3193b.jpg?1562818334"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Sublime Epiphany", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/d/ad1bcb44-a562-4f66-b862-6d0ef3546ab4.jpg?1594735795", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/d/ad1bcb44-a562-4f66-b862-6d0ef3546ab4.jpg?1594735795"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Suffocating Blast", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/2/c2a70297-2a7b-4a0c-ace5-cd61bfe6dafd.jpg?1562940975", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/2/c2a70297-2a7b-4a0c-ace5-cd61bfe6dafd.jpg?1562940975"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Summary Dismissal", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/b/0b75794d-3334-4b4d-9446-0a251dd3bd15.jpg?1576384222", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/b/0b75794d-3334-4b4d-9446-0a251dd3bd15.jpg?1576384222"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Summoner's Bane", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/d/ed82afba-df51-4bd9-853c-d3ef323095a6.jpg?1562618060", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/d/ed82afba-df51-4bd9-853c-d3ef323095a6.jpg?1562618060"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Supreme Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/6/b677e7cb-7b5d-4993-8f13-881493c498ce.jpg?1562811958", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/6/b677e7cb-7b5d-4993-8f13-881493c498ce.jpg?1562811958"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Swan Song", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/f/efd26041-059b-4a1e-9ce8-c3cfd69a3721.jpg?1562837218", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/f/efd26041-059b-4a1e-9ce8-c3cfd69a3721.jpg?1562837218"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Swan Song", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/0/40fc6412-df1c-4bfa-842b-8c3a6f14e19d.jpg?1599358784", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/0/40fc6412-df1c-4bfa-842b-8c3a6f14e19d.jpg?1599358784"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Swift Silence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/a/1/a1c5f733-e126-4c22-b528-18bdb90b509b.jpg?1593273784", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/a/1/a1c5f733-e126-4c22-b528-18bdb90b509b.jpg?1593273784"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Syncopate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/8/08375017-4432-4296-9799-966db145ed7c.jpg?1643588741", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/8/08375017-4432-4296-9799-966db145ed7c.jpg?1643588741"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Syncopate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/8/f81739a5-35a7-4812-a7af-e1951bf5579c.jpg?1617884773", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/8/f81739a5-35a7-4812-a7af-e1951bf5579c.jpg?1617884773"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Syncopate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba6f218f-83b0-4b68-a00f-0327cd79f32a.jpg?1562792232", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba6f218f-83b0-4b68-a00f-0327cd79f32a.jpg?1562792232"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Syncopate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/7/b7850794-4c85-4844-a461-650cd4eaec93.jpg?1562929140", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/7/b7850794-4c85-4844-a461-650cd4eaec93.jpg?1562929140"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Syphon Essence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/3/435a2d31-ac2c-45aa-8369-6c2d6fbba4e4.jpg?1643588767", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/3/435a2d31-ac2c-45aa-8369-6c2d6fbba4e4.jpg?1643588767"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Tale's End", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/1/4/1421115b-9a98-4ab2-bcb2-7d8899ce12db.jpg?1592516519", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/1/4/1421115b-9a98-4ab2-bcb2-7d8899ce12db.jpg?1592516519"}, "reprint": false, "digital": false, "set_type": "core"}, {"name": "Teferi's Response", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/3/f3bb2df8-c559-4a34-83b0-d48fbc694cc8.jpg?1562944007", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/3/f3bb2df8-c559-4a34-83b0-d48fbc694cc8.jpg?1562944007"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Temur Charm", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/2/e2ee3e36-a849-42b0-b84b-027a08427c35.jpg?1562794960", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/2/e2ee3e36-a849-42b0-b84b-027a08427c35.jpg?1562794960"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Test of Talents", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6e2b6236-b40c-430c-98b0-7940b942657a.jpg?1624590572", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6e2b6236-b40c-430c-98b0-7940b942657a.jpg?1624590572"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thassa's Intervention", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/c/2c1241d0-20d4-4eab-970d-74e476f023b4.jpg?1584279765", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/c/2c1241d0-20d4-4eab-970d-74e476f023b4.jpg?1584279765"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thassa's Rebuff", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/1/816a6ff7-cede-4346-b3e6-aee33aefac3a.jpg?1593091807", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/1/816a6ff7-cede-4346-b3e6-aee33aefac3a.jpg?1593091807"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thoughtbind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/7/9/7919cf41-67bb-4dc4-90de-cf3fa2096c2e.jpg?1593860622", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/9/7919cf41-67bb-4dc4-90de-cf3fa2096c2e.jpg?1593860622"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thought Collapse", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/4/948b569b-6341-418b-99b5-f79dfb3fe8dd.jpg?1584830401", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/4/948b569b-6341-418b-99b5-f79dfb3fe8dd.jpg?1584830401"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Thwart", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/1/c12a0717-e9ea-4be3-a29f-179671ed4489.jpg?1562383015", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/1/c12a0717-e9ea-4be3-a29f-179671ed4489.jpg?1562383015"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Tibalt's Trickery", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/d/dd921e27-3e08-438c-bec2-723226d35175.jpg?1652278784", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/d/dd921e27-3e08-438c-bec2-723226d35175.jpg?1652278784"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Time Stop", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/9/f968c5e9-12a8-4542-90b4-84e0238fa375.jpg?1562766084", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/9/f968c5e9-12a8-4542-90b4-84e0238fa375.jpg?1562766084"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Trap Essence", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/0/c063b2b8-5243-43a8-8cb0-927116003bda.jpg?1562701652", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/0/c063b2b8-5243-43a8-8cb0-927116003bda.jpg?1562701652"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Traumatic Visions", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/1/f1e8b03d-9265-4699-b626-5efa73292d43.jpg?1562804612", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/1/f1e8b03d-9265-4699-b626-5efa73292d43.jpg?1562804612"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Trickbind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f2e58ff2-dea3-42b3-8c22-3e6202a7d433.jpg?1562946300", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f2e58ff2-dea3-42b3-8c22-3e6202a7d433.jpg?1562946300"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Turn Aside", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/b/3b7573c2-484c-4b4e-9c26-0f005bd1daee.jpg?1576384240", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/b/3b7573c2-484c-4b4e-9c26-0f005bd1daee.jpg?1576384240"}, "reprint": true, "digital": false, "set_type": "expansion"}, {"name": "Turn Aside", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/5/6/56226f57-6ff0-430e-aba6-6b3dd51f8d3c.jpg?1562817712", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/5/6/56226f57-6ff0-430e-aba6-6b3dd51f8d3c.jpg?1562817712"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Undermine", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/3/2334bc71-5f85-47ff-b393-601a1e746a4e.jpg?1562902053", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/3/2334bc71-5f85-47ff-b393-601a1e746a4e.jpg?1562902053"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Undersimplify", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/3/e/3eaebdc1-7a20-45db-9d45-0238fc917496.jpg?1656479084", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/e/3eaebdc1-7a20-45db-9d45-0238fc917496.jpg?1656479084"}, "reprint": false, "digital": true, "set_type": "alchemy"}, {"name": "Unified Will", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/c/6cb50db7-f1d4-4f9d-ac60-564398af79ea.jpg?1562704807", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/c/6cb50db7-f1d4-4f9d-ac60-564398af79ea.jpg?1562704807"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Unsubstantiate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/ba5dac3d-4b49-44c4-a7b2-0a99485252c9.jpg?1576384246", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/ba5dac3d-4b49-44c4-a7b2-0a99485252c9.jpg?1576384246"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Unsubstantiate", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/b/8b184d7e-46ae-450e-9228-eb605ac3ad41.jpg?1562924384", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/b/8b184d7e-46ae-450e-9228-eb605ac3ad41.jpg?1562924384"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Unwind", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/7/97da6607-9131-4f8b-8af3-63439a59b78b.jpg?1562739909", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/7/97da6607-9131-4f8b-8af3-63439a59b78b.jpg?1562739909"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Verdant Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/8/3/83031ea8-a6c9-4318-af16-bba701dd76bb.jpg?1626097990", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/8/3/83031ea8-a6c9-4318-af16-bba701dd76bb.jpg?1626097990"}, "reprint": false, "digital": false, "set_type": "draft_innovation"}, {"name": "Verdant Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/0/7/070a3f30-0839-4678-a37c-475ee189811e.jpg?1626101883", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/7/070a3f30-0839-4678-a37c-475ee189811e.jpg?1626101883"}, "reprint": false, "frame_effects": ["showcase"], "digital": false, "set_type": "draft_innovation"}, {"name": "Very Cryptic Command", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/8/d8e84dd2-01f9-4fad-8a24-cc86424d09a2.jpg?1562940811", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/8/d8e84dd2-01f9-4fad-8a24-cc86424d09a2.jpg?1562940811"}, "reprint": false, "digital": false, "set_type": "funny"}, {"name": "Vex", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/2/e28a9f15-5469-4dc2-8a73-646f854fec7e.jpg?1562640140", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/2/e28a9f15-5469-4dc2-8a73-646f854fec7e.jpg?1562640140"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Void Shatter", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/b/4bf13c5e-3968-48ad-ba08-99ba58873223.jpg?1562910363", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/b/4bf13c5e-3968-48ad-ba08-99ba58873223.jpg?1562910363"}, "reprint": false, "frame_effects": ["devoid"], "digital": false, "set_type": "expansion"}, {"name": "Voidslime", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/e/6/e640664f-5cc7-4970-b966-6e6e5ae09c5a.jpg?1640462194", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/6/e640664f-5cc7-4970-b966-6e6e5ae09c5a.jpg?1640462194"}, "reprint": true, "digital": false, "set_type": "box"}, {"name": "Voidslime", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/2/6/265c269e-1b5e-4e5f-873f-7733bd4142aa.jpg?1562384947", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/2/6/265c269e-1b5e-4e5f-873f-7733bd4142aa.jpg?1562384947"}, "reprint": true, "digital": false, "set_type": "promo"}, {"name": "Warping Wail", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/2/f2ef4db8-b51c-4f52-84f1-6fee31c4a14c.jpg?1562943843", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/2/f2ef4db8-b51c-4f52-84f1-6fee31c4a14c.jpg?1562943843"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Wash Away", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/4/3/43411ade-be80-4535-8baa-7055e78496df.jpg?1643588844", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/4/3/43411ade-be80-4535-8baa-7055e78496df.jpg?1643588844"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Whirlwind Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/9/e/9e127856-bedd-40a9-9e8e-d1f9fbefe07d.jpg?1581479658", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/9/e/9e127856-bedd-40a9-9e8e-d1f9fbefe07d.jpg?1581479658"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Whirlwind Denial", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/f/7/f7a0c25a-8760-44ea-a418-fcd4a9761632.jpg?1623594049", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/f/7/f7a0c25a-8760-44ea-a418-fcd4a9761632.jpg?1623594049"}, "reprint": true, "digital": false, "set_type": "masterpiece"}, {"name": "Wild Ricochet", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/d/7/d76f09bc-b49a-4ad2-be2d-2a191d41b86d.jpg?1562370137", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/7/d76f09bc-b49a-4ad2-be2d-2a191d41b86d.jpg?1562370137"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Withering Boon", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/6/e/6e6499cb-6073-4c94-8c82-47f489094df5.jpg?1562719780", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/e/6e6499cb-6073-4c94-8c82-47f489094df5.jpg?1562719780"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "Wizard's Retort", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/b/a/bae30b7d-9306-46ef-adea-c4057f59c9c1.jpg?1562741944", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/b/a/bae30b7d-9306-46ef-adea-c4057f59c9c1.jpg?1562741944"}, "reprint": false, "digital": false, "set_type": "expansion"}, {"name": "You Find the Villains' Lair", "image_uris": {"normal": "https://c1.scryfall.com/file/scryfall-cards/normal/front/c/6/c6704458-6e9e-4795-a56d-25b68fbf9672.jpg?1627704159", "art_crop": "https://c1.scryfall.com/file/scryfall-cards/art_crop/front/c/6/c6704458-6e9e-4795-a56d-25b68fbf9672.jpg?1627704159"}, "reprint": false, "digital": false, "set_type": "expansion"}]} \ No newline at end of file diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 31c0c533..3457b7a6 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,4 +1,5 @@ const defaultTheme = require('tailwindcss/defaultTheme') +const plugin = require('tailwindcss/plugin') module.exports = { content: [ @@ -17,6 +18,14 @@ module.exports = { backgroundImage: { 'world-trading': "url('/world-trading-background.webp')", }, + typography: { + quoteless: { + css: { + 'blockquote p:first-of-type::before': { content: 'none' }, + 'blockquote p:first-of-type::after': { content: 'none' }, + }, + }, + }, }, }, plugins: [ @@ -24,6 +33,22 @@ module.exports = { require('@tailwindcss/typography'), require('@tailwindcss/line-clamp'), require('daisyui'), + plugin(function ({ addUtilities }) { + addUtilities({ + '.scrollbar-hide': { + /* IE and Edge */ + '-ms-overflow-style': 'none', + + /* Firefox */ + 'scrollbar-width': 'none', + + /* Safari and Chrome */ + '&::-webkit-scrollbar': { + display: 'none', + }, + }, + }) + }), ], daisyui: { themes: [ diff --git a/yarn.lock b/yarn.lock index 6fcdf53a..ffa8e6f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2385,10 +2385,10 @@ resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== -"@next/env@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/env/-/env-12.1.2.tgz#4b0f5fd448ac60b821d2486d2987948e3a099f03" - integrity sha512-A/P4ysmFScBFyu1ZV0Mr1Y89snyQhqGwsCrkEpK+itMF+y+pMqBoPVIyakUf4LXqGWJGiGFuIerihvSG70Ad8Q== +"@next/env@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc" + integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw== "@next/eslint-plugin-next@12.1.6": version "12.1.6" @@ -2397,65 +2397,70 @@ dependencies: glob "7.1.7" -"@next/swc-android-arm-eabi@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.2.tgz#675e952d9032ac7bec02f3f413c17d33bbd90857" - integrity sha512-iwalfLBhYmCIlj09czFbovj1SmTycf0AGR8CB357wgmEN8xIuznIwSsCH87AhwQ9apfNtdeDhxvuKmhS9T3FqQ== +"@next/swc-android-arm-eabi@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd" + integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ== -"@next/swc-android-arm64@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.2.tgz#d9710c50853235f258726b19a649df9c29a49682" - integrity sha512-ZoR0Vx7czJhTgRAcFbzTKQc2n2ChC036/uc6PbgYiI/LreEnfmsV/CiREP0pUVs5ndntOX8kBA3BSbh4zCO5tQ== +"@next/swc-android-arm64@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e" + integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA== -"@next/swc-darwin-arm64@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.2.tgz#aadd21b711c82b3efa9b4ecf7665841259e1fa7e" - integrity sha512-VXv7lpqFjHwkK65CZHkjvBxlSBTG+l3O0Zl2zHniHj0xHzxJZvR8VFjV2zIMZCYSfVqeQ5yt2rjwuQ9zbpGtXQ== +"@next/swc-darwin-arm64@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50" + integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA== -"@next/swc-darwin-x64@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.2.tgz#3b1a389828f5c88ecb828a6394692fdeaf175081" - integrity sha512-evXxJQnXEnU+heWyun7d0UV6bhBcmoiyFGR3O3v9qdhGbeXh+SXYVxRO69juuh6V7RWRdlb1KQ0rGUNa1k0XSw== +"@next/swc-darwin-x64@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133" + integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw== -"@next/swc-linux-arm-gnueabihf@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.2.tgz#db4371ca716bf94c94d4f6b001ac3c9d08d97d79" - integrity sha512-LJV/wo6R0Ot7Y/20bZs00aBG4J333RT6H/5Q2AROE4Hnx7cenSktSnfU6WCnJgzYLSIHdbLs549LcZMULuVquw== +"@next/swc-freebsd-x64@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95" + integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA== -"@next/swc-linux-arm64-gnu@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.2.tgz#0e71db03b8b12ed315c8be7d15392ecefe562b7c" - integrity sha512-fjlYU1Y8kVjjRKyuyQBYLHPxjGOS2ox7U8TqAvtgKvd2PxqdsgW4sP+VDovRVPrZlGXNllKoJiqMO1OoR9fB6w== +"@next/swc-linux-arm-gnueabihf@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6" + integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q== -"@next/swc-linux-arm64-musl@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.2.tgz#f1b055793da1c12167ed3b6e32aef8289721a1fb" - integrity sha512-Y1JRDMHqSjLObjyrD1hf6ePrJcOF/mkw+LbAzoNgrHL1dSuIAqcz3jYunJt8T7Yw48xSJy6LPSL9BclAHwEwOA== +"@next/swc-linux-arm64-gnu@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061" + integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw== -"@next/swc-linux-x64-gnu@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.2.tgz#69764ffaacb3b9b373897fff15d7dd871455efe2" - integrity sha512-5N4QSRT60ikQqCU8iHfYZzlhg6MFTLsKhMTARmhn8wLtZfN9VVyTFwZrJQWjV64dZc4JFeXDANGao8fm55y6bw== +"@next/swc-linux-arm64-musl@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56" + integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A== -"@next/swc-linux-x64-musl@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.2.tgz#0ddaedb5ec578c01771f83be2046dafb2f70df91" - integrity sha512-b32F/xAgdYG4Pt0foFzhF+2uhvNxnEj7aJNp1R4EhZotdej2PzvFWcP/dGkc7MJl205pBz5oC3gHyILIIlW6XA== +"@next/swc-linux-x64-gnu@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78" + integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A== -"@next/swc-win32-arm64-msvc@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.2.tgz#9e17ed56d5621f8c6961193da3a0b155cea511c9" - integrity sha512-hVOcGmWDeVwO00Aclopsj6MoYhfJl5zA4vjAai9KjgclQTFZa/DC0vQjgKAHHKGT5oMHgjiq/G7L6P1/UfwYnw== +"@next/swc-linux-x64-musl@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a" + integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw== -"@next/swc-win32-ia32-msvc@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.2.tgz#ddd260cbe8bc4002fb54415b80baccf37f8db783" - integrity sha512-wnVDGIVz2pR3vIkyN6IE+1NvMSBrBj1jba11iR16m8TAPzZH/PrNsxr0a9N5VavEXXLcQpoUVvT+N7nflbRAHg== +"@next/swc-win32-arm64-msvc@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157" + integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg== -"@next/swc-win32-x64-msvc@12.1.2": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.2.tgz#37412a314bcf4c6006a74e1ef9764048344f3848" - integrity sha512-MLNcurEpQp0+7OU9261f7PkN52xTGkfrt4IYTIXau7DO/aHj927oK6piIJdl9EOHdX/KN5W6qlyErj170PSHtw== +"@next/swc-win32-ia32-msvc@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f" + integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA== + +"@next/swc-win32-x64-msvc@12.2.2": + version "12.2.2" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89" + integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ== "@nivo/annotations@0.74.0": version "0.74.0" @@ -2837,6 +2842,13 @@ "@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-svgo" "^6.2.0" +"@swc/helpers@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.2.tgz#ed1f6997ffbc22396665d9ba74e2a5c0a2d782f8" + integrity sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw== + dependencies: + tslib "^2.4.0" + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -4290,7 +4302,7 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001335: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz#8a1e7fdc4db9c2ec79a05e9fd68eb93a761888bb" integrity sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g== -caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001283, caniuse-lite@^1.0.30001332: +caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332: version "1.0.30001341" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498" integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== @@ -8320,29 +8332,31 @@ next-sitemap@^2.5.14: "@corex/deepmerge" "^2.6.148" minimist "^1.2.6" -next@12.1.2: - version "12.1.2" - resolved "https://registry.yarnpkg.com/next/-/next-12.1.2.tgz#c5376a8ae17d3e404a2b691c01f94c8943306f29" - integrity sha512-JHPCsnFTBO0Z4SQxSYc611UA1WA+r/3y3Neg66AH5/gSO/oksfRnFw/zGX/FZ9+oOUHS9y3wJFawNpVYR2gJSQ== +next@12.2.2: + version "12.2.2" + resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072" + integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg== dependencies: - "@next/env" "12.1.2" - caniuse-lite "^1.0.30001283" + "@next/env" "12.2.2" + "@swc/helpers" "0.4.2" + caniuse-lite "^1.0.30001332" postcss "8.4.5" - styled-jsx "5.0.1" - use-subscription "1.5.1" + styled-jsx "5.0.2" + use-sync-external-store "1.1.0" optionalDependencies: - "@next/swc-android-arm-eabi" "12.1.2" - "@next/swc-android-arm64" "12.1.2" - "@next/swc-darwin-arm64" "12.1.2" - "@next/swc-darwin-x64" "12.1.2" - "@next/swc-linux-arm-gnueabihf" "12.1.2" - "@next/swc-linux-arm64-gnu" "12.1.2" - "@next/swc-linux-arm64-musl" "12.1.2" - "@next/swc-linux-x64-gnu" "12.1.2" - "@next/swc-linux-x64-musl" "12.1.2" - "@next/swc-win32-arm64-msvc" "12.1.2" - "@next/swc-win32-ia32-msvc" "12.1.2" - "@next/swc-win32-x64-msvc" "12.1.2" + "@next/swc-android-arm-eabi" "12.2.2" + "@next/swc-android-arm64" "12.2.2" + "@next/swc-darwin-arm64" "12.2.2" + "@next/swc-darwin-x64" "12.2.2" + "@next/swc-freebsd-x64" "12.2.2" + "@next/swc-linux-arm-gnueabihf" "12.2.2" + "@next/swc-linux-arm64-gnu" "12.2.2" + "@next/swc-linux-arm64-musl" "12.2.2" + "@next/swc-linux-x64-gnu" "12.2.2" + "@next/swc-linux-x64-musl" "12.2.2" + "@next/swc-win32-arm64-msvc" "12.2.2" + "@next/swc-win32-ia32-msvc" "12.2.2" + "@next/swc-win32-x64-msvc" "12.2.2" no-case@^3.0.4: version "3.0.4" @@ -10892,10 +10906,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -styled-jsx@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.1.tgz#78fecbbad2bf95ce6cd981a08918ce4696f5fc80" - integrity sha512-+PIZ/6Uk40mphiQJJI1202b+/dYeTVd9ZnMPR80pgiWbjIwvN2zIp4r9et0BgqBuShh48I0gttPlAXA7WVvBxw== +styled-jsx@5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.2.tgz#ff230fd593b737e9e68b630a694d460425478729" + integrity sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ== stylehacks@^5.1.0: version "5.1.0" @@ -11437,12 +11451,10 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" -use-subscription@1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" - integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA== - dependencies: - object-assign "^4.1.1" +use-sync-external-store@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82" + integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ== util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2"