From 6c4e870d5dac8a3fa2224660aabf77286bdf8e3f Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 17 Aug 2022 11:39:08 -0600 Subject: [PATCH] Rich text to plaintext descriptions, other ui changes --- common/contract-details.ts | 151 ++++++++++++++++++ firestore.rules | 2 +- functions/src/emails.ts | 51 ++++++ functions/src/index.ts | 1 + functions/src/unsubscribe.ts | 4 + functions/src/weekly-markets-emails.ts | 57 +++---- web/components/SEO.tsx | 56 +------ .../contract/contract-card-preview.tsx | 44 ----- web/components/contract/contract-details.tsx | 26 +-- web/components/contract/quick-bet.tsx | 3 +- web/components/feed/feed-items.tsx | 2 +- web/lib/firebase/contracts.ts | 26 +-- web/pages/[username]/[contractSlug].tsx | 2 +- .../[contractSlug]/[challengeSlug].tsx | 2 +- 14 files changed, 235 insertions(+), 192 deletions(-) create mode 100644 common/contract-details.ts delete mode 100644 web/components/contract/contract-card-preview.tsx diff --git a/common/contract-details.ts b/common/contract-details.ts new file mode 100644 index 00000000..02af6359 --- /dev/null +++ b/common/contract-details.ts @@ -0,0 +1,151 @@ +import { Challenge } from './challenge' +import { BinaryContract, Contract } from './contract' +import { getFormattedMappedValue } from './pseudo-numeric' +import { getProbability } from './calculate' +import { richTextToString } from './util/parse' +import { getCpmmProbability } from './calculate-cpmm' +import { getDpmProbability } from './calculate-dpm' +import { formatMoney, formatPercent } from './util/format' + +export function contractMetrics(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { createdTime, resolutionTime, isResolved } = contract + + const createdDate = dayjs(createdTime).format('MMM D') + + const resolvedDate = isResolved + ? dayjs(resolutionTime).format('MMM D') + : undefined + + const volumeLabel = `${formatMoney(contract.volume)} bet` + + return { volumeLabel, createdDate, resolvedDate } +} + +// String version of the above, to send to the OpenGraph image generator +export function contractTextDetails(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { closeTime, tags } = contract + const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) + + const hashtags = tags.map((tag) => `#${tag}`) + + return ( + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + + (closeTime + ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( + closeTime + ).format('MMM D, h:mma')}` + : '') + + ` • ${volumeLabel}` + + (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + ) +} + +export function getBinaryProb(contract: BinaryContract) { + const { pool, resolutionProbability, mechanism } = contract + + return ( + resolutionProbability ?? + (mechanism === 'cpmm-1' + ? getCpmmProbability(pool, contract.p) + : getDpmProbability(contract.totalShares)) + ) +} + +export const getOpenGraphProps = (contract: Contract) => { + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + description: desc, + } = contract + const probPercent = + outcomeType === 'BINARY' + ? formatPercent(getBinaryProb(contract)) + : undefined + + const numericValue = + outcomeType === 'PSEUDO_NUMERIC' + ? getFormattedMappedValue(contract)(getProbability(contract)) + : undefined + + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + + const description = resolution + ? `Resolved ${resolution}. ${stringDesc}` + : probPercent + ? `${probPercent} chance. ${stringDesc}` + : stringDesc + + return { + question, + probability: probPercent, + metadata: contractTextDetails(contract), + creatorName, + creatorUsername, + creatorAvatarUrl, + description, + numericValue, + } +} + +export type OgCardProps = { + question: string + probability?: string + metadata: string + creatorName: string + creatorUsername: string + creatorAvatarUrl?: string + numericValue?: string +} + +export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + + const probabilityParam = + props.probability === undefined + ? '' + : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + + const creatorAvatarUrlParam = + props.creatorAvatarUrl === undefined + ? '' + : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + + const challengeUrlParams = challenge + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + + `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + + `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` + : '' + + // URL encode each of the props, then add them as query params + return ( + `https://manifold-og-image.vercel.app/m.png` + + `?question=${encodeURIComponent(props.question)}` + + probabilityParam + + numericValueParam + + `&metadata=${encodeURIComponent(props.metadata)}` + + `&creatorName=${encodeURIComponent(props.creatorName)}` + + creatorAvatarUrlParam + + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams + ) +} diff --git a/firestore.rules b/firestore.rules index 81ab4eed..c0d17dac 100644 --- a/firestore.rules +++ b/firestore.rules @@ -63,7 +63,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); } match /private-users/{userId}/views/{viewId} { diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 19c7d4e4..5a806544 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -18,6 +18,7 @@ import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' import { richTextToString } from '../../common/util/parse' +import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -390,3 +391,53 @@ export const sendNewAnswerEmail = async ( { from } ) } + +export const sendThreeContractsEmail = async ( + privateUser: PrivateUser, + contractsToSend: Contract[] +) => { + const emailType = 'weekly-trending' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + if (!privateUser || !privateUser.email) return + await sendTemplateEmail( + privateUser.email, + contractsToSend[0].question + ' and 2 more questions for you.', + '3-trending-markets', + { + question1Title: contractsToSend[0].question, + question1Description: getTextDescription(contractsToSend[0]), + question1Link: contractUrl(contractsToSend[0]), + question1ImgSrc: imageSourceUrl(contractsToSend[0]), + question2Title: contractsToSend[1].question, + question2Description: getTextDescription(contractsToSend[1]), + question2Link: contractUrl(contractsToSend[1]), + question2ImgSrc: imageSourceUrl(contractsToSend[1]), + question3Title: contractsToSend[2].question, + question3Description: getTextDescription(contractsToSend[2]), + question3Link: contractUrl(contractsToSend[2]), + question3ImgSrc: imageSourceUrl(contractsToSend[2]), + + unsubscribeLink: unsubscribeUrl, + } + ) +} + +function getTextDescription(contract: Contract) { + const { description } = contract + let text = '' + if (typeof description === 'string') text = description + else text = richTextToString(description) + + if (text.length > 300) { + return text.substring(0, 300) + '...' + } + return text +} + +function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} + +function imageSourceUrl(contract: Contract) { + return buildCardUrl(getOpenGraphProps(contract)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 07b37648..10aaf456 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -25,6 +25,7 @@ export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' +export * from './weekly-markets-emails' // v2 export * from './health' diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index fda20e16..4db91539 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -21,6 +21,7 @@ export const unsubscribe: EndpointDefinition = { 'market-comment', 'market-answer', 'generic', + 'weekly-trending', ].includes(type) ) { res.status(400).send('Invalid type parameter.') @@ -49,6 +50,9 @@ export const unsubscribe: EndpointDefinition = { ...(type === 'generic' && { unsubscribedFromGenericEmails: true, }), + ...(type === 'weekly-trending' && { + unsubscribedFromWeeklyTrendingEmails: true, + }), } await firestore.collection('private-users').doc(id).update(update) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index cf5b5220..c6a7edf7 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,13 +2,20 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { getAllPrivateUsers, getPrivateUser, getValues, log } from './utils' -import { sendTemplateEmail } from './send-email' -import { createRNG, shuffle } from '../../common/util/random' +import { + getAllPrivateUsers, + getPrivateUser, + getValues, + isProd, + log, +} from './utils' import { filterDefined } from '../../common/util/array' +import { sendThreeContractsEmail } from './emails' +import { createRNG, shuffle } from '../../common/util/random' -export const weeklyMarketsEmails = functions.pubsub - .schedule('every 1 minutes') +export const weeklyMarketsEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('every 1 minutes') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) @@ -27,10 +34,11 @@ async function getTrendingContracts() { } async function sendTrendingMarketsEmailsToAllUsers() { - const numMarketsToSend = 3 // const privateUsers = await getAllPrivateUsers() // uses dev ian's private user for testing - const privateUser = await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2') + const privateUser = await getPrivateUser( + isProd() ? 'AJwLWoo3xue32XIiAVrL5SyR1WB2' : '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' + ) const privateUsers = filterDefined([privateUser]) // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { @@ -45,46 +53,17 @@ async function sendTrendingMarketsEmailsToAllUsers() { const contractsAvailableToSend = trendingContracts.filter((contract) => { return !contract.uniqueBettorIds?.includes(privateUser.id) }) - if (contractsAvailableToSend.length < numMarketsToSend) { + if (contractsAvailableToSend.length < 3) { log('not enough new, unbet-on contracts to send to user', privateUser.id) continue } // choose random subset of contracts to send to user - const contractsToSend = chooseRandomSubset( - contractsAvailableToSend, - numMarketsToSend - ) + const contractsToSend = chooseRandomSubset(contractsAvailableToSend, 3) - await sendTemplateEmail( - privateUser.email, - contractsToSend[0].question, - '3-trending-markets', - { - question1title: contractsToSend[0].question, - question1Description: getTextDescription(contractsToSend[0]), - question1link: contractUrl(contractsToSend[0]), - question2title: contractsToSend[1].question, - question2Description: getTextDescription(contractsToSend[1]), - question2link: contractUrl(contractsToSend[1]), - question3title: contractsToSend[2].question, - question3Description: getTextDescription(contractsToSend[2]), - question3link: contractUrl(contractsToSend[2]), - } - ) + await sendThreeContractsEmail(privateUser, contractsToSend) } } -function getTextDescription(contract: Contract) { - // if the contract.description is of type string, return it, otherwise return the text of the json content - return typeof contract.description === 'string' - ? contract.description - : contract.description.text ?? '' -} - -function contractUrl(contract: Contract) { - return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` -} - function chooseRandomSubset(contracts: Contract[], count: number) { const fiveMinutes = 5 * 60 * 1000 const seed = Math.round(Date.now() / fiveMinutes).toString() diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 08dee31e..2c9327ec 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,61 +1,7 @@ import { ReactNode } from 'react' import Head from 'next/head' import { Challenge } from 'common/challenge' - -export type OgCardProps = { - question: string - probability?: string - metadata: string - creatorName: string - creatorUsername: string - creatorAvatarUrl?: string - numericValue?: string -} - -function buildCardUrl(props: OgCardProps, challenge?: Challenge) { - const { - creatorAmount, - acceptances, - acceptorAmount, - creatorOutcome, - acceptorOutcome, - } = challenge || {} - const { userName, userAvatarUrl } = acceptances?.[0] ?? {} - - const probabilityParam = - props.probability === undefined - ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` - - const numericValueParam = - props.numericValue === undefined - ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` - - const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined - ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` - - const challengeUrlParams = challenge - ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + - `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + - `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` - : '' - - // URL encode each of the props, then add them as query params - return ( - `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + - probabilityParam + - numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + - creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams - ) -} +import { buildCardUrl, OgCardProps } from 'common/contract-details' export function SEO(props: { title: string diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx deleted file mode 100644 index 354fe308..00000000 --- a/web/components/contract/contract-card-preview.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Contract } from 'common/contract' -import { getBinaryProbPercent } from 'web/lib/firebase/contracts' -import { richTextToString } from 'common/util/parse' -import { contractTextDetails } from 'web/components/contract/contract-details' -import { getFormattedMappedValue } from 'common/pseudo-numeric' -import { getProbability } from 'common/calculate' - -export const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined - - const numericValue = - outcomeType === 'PSEUDO_NUMERIC' - ? getFormattedMappedValue(contract)(getProbability(contract)) - : undefined - - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) - - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc - - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - numericValue, - } -} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 71998b9d..69631b07 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -9,11 +9,7 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { - Contract, - contractMetrics, - updateContract, -} from 'web/lib/firebase/contracts' +import { Contract, updateContract } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -35,6 +31,7 @@ import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import clsx from 'clsx' +import { contractMetrics } from 'common/contract-details' export type ShowTime = 'resolve-date' | 'close-date' @@ -245,25 +242,6 @@ export function ContractDetails(props: { ) } -// String version of the above, to send to the OpenGraph image generator -export function contractTextDetails(contract: Contract) { - const { closeTime, tags } = contract - const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - - const hashtags = tags.map((tag) => `#${tag}`) - - return ( - `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + - (closeTime - ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( - closeTime - ).format('MMM D, h:mma')}` - : '') + - ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') - ) -} - function EditableCloseDate(props: { closeTime: number contract: Contract diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 92cee018..7ef371f0 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -23,7 +23,7 @@ import { useState } from 'react' import toast from 'react-hot-toast' import { useUserContractBets } from 'web/hooks/use-user-bets' import { placeBet } from 'web/lib/firebase/api' -import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts' +import { getBinaryProbPercent } from 'web/lib/firebase/contracts' import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' @@ -34,6 +34,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUnfilledBets } from 'web/hooks/use-bets' +import { getBinaryProb } from 'common/contract-details' const BET_SIZE = 10 diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index d60fb8da..85f58126 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -11,7 +11,6 @@ import clsx from 'clsx' import { OutcomeLabel } from '../outcome-label' import { Contract, - contractMetrics, contractPath, tradingAllowed, } from 'web/lib/firebase/contracts' @@ -38,6 +37,7 @@ import { FeedLiquidity } from './feed-liquidity' import { SignUpPrompt } from '../sign-up-prompt' import { User } from 'common/user' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' +import { contractMetrics } from 'common/contract-details' export function FeedItems(props: { contract: Contract diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index b31b8d04..c3e4f14d 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -26,6 +26,7 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' +import { getBinaryProb } from 'common/contract-details' export const contracts = coll('contracts') @@ -50,20 +51,6 @@ export function contractUrl(contract: Contract) { return `https://${ENV_CONFIG.domain}${contractPath(contract)}` } -export function contractMetrics(contract: Contract) { - const { createdTime, resolutionTime, isResolved } = contract - - const createdDate = dayjs(createdTime).format('MMM D') - - const resolvedDate = isResolved - ? dayjs(resolutionTime).format('MMM D') - : undefined - - const volumeLabel = `${formatMoney(contract.volume)} bet` - - return { volumeLabel, createdDate, resolvedDate } -} - export function contractPool(contract: Contract) { return contract.mechanism === 'cpmm-1' ? formatMoney(contract.totalLiquidity) @@ -72,17 +59,6 @@ export function contractPool(contract: Contract) { : 'Empty pool' } -export function getBinaryProb(contract: BinaryContract) { - const { pool, resolutionProbability, mechanism } = contract - - return ( - resolutionProbability ?? - (mechanism === 'cpmm-1' - ? getCpmmProbability(pool, contract.p) - : getDpmProbability(contract.totalShares)) - ) -} - export function getBinaryProbPercent(contract: BinaryContract) { return formatPercent(getBinaryProb(contract)) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 94773b6d..ba738108 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -36,12 +36,12 @@ import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { User } from 'common/user' import { listUsers } from 'web/lib/firebase/users' import { FeedComment } from 'web/components/feed/feed-comments' import { Title } from 'web/components/title' import { FeedBet } from 'web/components/feed/feed-bets' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index 55e78616..f15c5809 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -28,11 +28,11 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { useWindowSize } from 'web/hooks/use-window-size' import { Bet, listAllBets } from 'web/lib/firebase/bets' import { SEO } from 'web/components/SEO' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import Custom404 from 'web/pages/404' import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz)