From 73fc67955d279a4d729bb295e2f4e8309ea82755 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Fri, 29 Apr 2022 16:35:56 -0700 Subject: [PATCH] Send M$ to Charity & txns (#81) * Add components for CPM landing and charity pages * Remove misc.ts to fix build * Set up cloud function for writing txns * More plumbing for txns * Fix up API call * Use Date.now() to keep timestamps simple * Some styles for charity list page * Hard code charities data * Pass charity data to charity page * Update txn type * Listen for charity txns * Handle txn to non-user by burning it * Read txns for charity card and charity page. * Set images to object contain * Clean up txn types * Move pic to top of card. Other misc styling. * Update charity short & long descriptions * Add `token` and `category` to Txn * Fix breakages * Show Charity link in the sidebar * Fix typing issues * Fix not reading from the right type * Switch out icon * Also show Charity icon on mobile * Update copy Co-authored-by: Austin Chen Co-authored-by: James Grugett --- common/charity.ts | 282 ++++++++++++++++++++++++ common/txn.ts | 21 ++ firestore.rules | 5 + functions/src/backup-db.ts | 1 + functions/src/index.ts | 1 + functions/src/transact.ts | 82 +++++++ web/components/charity/charity-card.tsx | 34 +++ web/components/nav/nav-bar.tsx | 7 +- web/components/nav/sidebar.tsx | 7 +- web/hooks/use-charity-txns.ts | 13 ++ web/lib/firebase/api-call.ts | 6 + web/lib/firebase/txns.ts | 23 ++ web/package.json | 1 + web/pages/charity/[charitySlug].tsx | 212 ++++++++++++++++++ web/pages/charity/index.tsx | 64 ++++++ web/tailwind.config.js | 1 + yarn.lock | 5 + 17 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 common/charity.ts create mode 100644 common/txn.ts create mode 100644 functions/src/transact.ts create mode 100644 web/components/charity/charity-card.tsx create mode 100644 web/hooks/use-charity-txns.ts create mode 100644 web/lib/firebase/txns.ts create mode 100644 web/pages/charity/[charitySlug].tsx create mode 100644 web/pages/charity/index.tsx diff --git a/common/charity.ts b/common/charity.ts new file mode 100644 index 00000000..a4cfd551 --- /dev/null +++ b/common/charity.ts @@ -0,0 +1,282 @@ +export interface Charity { + id: string + slug: string + name: string + website: string + ein: string + photo?: string + preview: string + description: string +} + +export const charities: Charity[] = [ + { + name: 'Faunalytics', + website: 'https://faunalytics.org/', + ein: '01-0686889', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + preview: + 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', + description: + "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", + }, + { + name: 'The Humane League', + website: 'https://thehumaneleague.org/', + ein: '04-3817491', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + preview: + 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', + description: + 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', + }, + { + name: 'Wild Animal Initiative', + website: 'https://www.wildanimalinitiative.org/', + ein: '82-2281466', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + preview: 'We want to make life better for wild animals.', + description: + 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + }, + { + name: 'Give Directly', + website: 'https://www.givedirectly.org/', + ein: '27-1661997', + photo: + 'https://www.givewell.org/sites/default/files/charity_logos/GiveDirectly.jpg', + preview: 'Send money directly to people living in poverty.', + description: + 'GiveDirectly is a nonprofit that lets donors like you send money directly to the world’s poorest households. We believe people living in poverty deserve the dignity to choose for themselves how best to improve their lives — cash enables that choice. Since 2009, we’ve delivered $500M+ in cash directly into the hands of over 1 million families living in poverty. We currently have operations in Kenya, Rwanda, Liberia, Malawi, Morocco, Mozambique, DRC, Uganda, and the United States.', + }, + { + name: 'Hellen Keller International', + website: 'https://www.hki.org/', + ein: '13-5562162', + photo: + 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', + preview: + 'At Helen Keller Intl, we envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', + description: + 'Right now, 36 million people worldwide — most of them in developing countries — are blind.\n 90 percent of them didn’t have to lose their sight. Helen Keller International is dedicated to combating the causes and consequences of vision loss and making clear vision a reality for those most vulnerable to disease and who lack access to quality eye care.\n Last year alone, we helped provide many tens of millions of people with treatment to prevent diseases of poverty including blinding trachoma and river blindness.\n Surgeons trained by our staff also performed tens of thousands of cataract surgeries in the developing world.  And in the United States, we screened the vision of nearly 66,000 students living in some of our country’s poorest neighborhoods and provided free eyeglasses to just over 16,000 of them. ', + }, + { + name: 'Against Malaria Foundation', + website: 'https://www.againstmalaria.com/', + ein: '20-3069841', + photo: + 'https://media-exp1.licdn.com/dms/image/C4D0BAQFvdcum9KBNfg/company-logo_200_200/0?e=2159024400&v=beta&t=hxjJCKQkMp2irTOcuJEceW7x4l3c4PD7gYCQ6ulgYlg', + preview: 'We help protect people from malaria.', + description: + 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', + }, + { + name: 'Malaria Consortium', + website: 'https://www.malariaconsortium.org/', + ein: '98-0627052', + photo: + 'http://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + preview: + 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', + description: + 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', + }, + { + name: 'New Incentives', + website: 'https://www.newincentives.org/', + ein: '45-2368993', + photo: + 'https://uploads-ssl.webflow.com/5f7c51bf9fac9b5ed62aa37b/5f7c51bf9fac9b85c42aa3df_Group%20344%20(1).svg', + preview: 'Cash incentives to boost vaccination rates and save lives.', + description: + 'New Incentives (newincentives.org) runs a conditional cash transfer (CCT) program in North West Nigeria which seeks to increase uptake of routine immunizations through cash transfers, raising public awareness of the benefits of vaccination and reducing the frequency of vaccine stockouts.', + }, + { + name: 'SCI foundation', + website: 'https://schistosomiasiscontrolinitiative.org/', + ein: '', + photo: + 'https://images.easyfundraising.org.uk/cause/cropped/cause-logo-e99e0632a8a9572150fdcf3abf08ad45.png', + preview: + 'SCI works with governments in sub-Saharan Africa to create or scale up programs that treat schistosomiasis and soil-transmitted helminthiasis ("deworming").', + description: + 'We’re a non-profit initiative supporting governments in sub-Saharan African countries. We support them to develop sustainable, cost-effective programmes against parasitic worm infections such as schistosomiasis and intestinal worms.  Since our foundation in 2002, we’ve contributed to the delivery of over 200 million treatments against these diseases. The programmes are highly effective; parasitic worm infections can be reduced by up to 60% after just one round of treatment.', + }, + { + name: 'Wikimedia Foundation', + website: 'https://wikimediafoundation.org/', + ein: '20-0049703', + photo: + 'http://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + preview: 'We help everyone share in the sum of all knowledge.', + description: + 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', + }, + { + name: 'Rainforest Trust', + website: 'https://www.rainforesttrust.org/', + ein: '13-3500609', + photo: + 'http://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + preview: + 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', + description: + 'Our unique, cost-effective conservation model for protecting endangered species has been implemented successfully for over 30 years. Thanks to the generosity of our donors, the expertise of our partners and the participation of local communities across the tropics, our reserves are exemplary models of international conservation.', + }, + { + name: 'The Nature Conservancy', + website: 'https://www.nature.org/en-us/', + ein: '53-0242652', + photo: + 'https://mma.prnewswire.com/media/1140905/The_Nature_Conservancy_Logo.jpg?p=facebook', + preview: 'A Future Where People and Nature Thrive', + description: + 'The Nature Conservancy is a global environmental nonprofit working to create a world where people and nature can thrive. Founded in the U.S. through grassroots action in 1951, The Nature Conservancy has grown to become one of the most effective and wide-reaching environmental organizations in the world. Thanks to more than a million members and the dedicated efforts of our diverse staff and over 400 scientists, we impact conservation in 76 countries and territories: 37 by direct conservation impact and 39 through partners.', + }, + { + name: 'Doctors Without Borders', + website: 'https://www.doctorswithoutborders.org/', + ein: '13-3433452', + photo: 'https://www.doctorswithoutborders.org/themes/custom/msf/logo.svg', + preview: + 'We provide independent, impartial medical humanitarian assistance to the people who need it most.', + description: + 'Doctors Without Borders/Médecins Sans Frontières (MSF) cares for people affected by conflict, disease outbreaks, natural and human-made disasters, and exclusion from health care in more than 70 countries.', + }, + { + name: 'World Wildlife Fund', + website: 'https://www.worldwildlife.org/', + ein: '52-1693387', + photo: + 'https://www.worldwildlife.org/assets/structure/unique/logo-c562409bb6158bf64e5f8b1be066dbd5983d75f5ce7c9935a5afffbcc03f8e5d.png', + preview: + 'WWF works to sustain the natural world for the benefit of people and wildlife, collaborating with partners from local to global levels in nearly 100 countries.', + description: + 'As the world’s leading conservation organization, WWF works in nearly 100 countries to tackle the most pressing issues at the intersection of nature, people, and climate. We collaborate with local communities to conserve the natural resources we all depend on and build a future in which people and nature thrive. Together with partners at all levels, we transform markets and policies toward sustainability, tackle the threats driving the climate crisis, and protect and restore wildlife and their habitats.', + }, + { + name: 'UNICEF USA', + website: 'https://www.unicefusa.org/', + ein: '13-1760110', + preview: + "UNICEF USA helps save and protect the world's most vulnerable children.", + description: + 'Over eight decades, the United Nations Children’s Fund (UNICEF) has built an unprecedented global support system for the world’s children. UNICEF relentlessly works day in and day out to deliver the essentials that give every child an equitable chance in life: health care and immunizations, safe water and sanitation, nutrition, education, emergency relief and more. UNICEF USA advances the global mission of UNICEF by rallying the American public to support the world’s most vulnerable children. Together, we have helped save more children’s lives than any other humanitarian organization.', + }, + { + name: 'Vitamin Angels', + website: 'https://www.vitaminangels.org/', + ein: '77-0485881', + photo: + 'https://www.newhope.com/sites/newhope360.com/files/styles/article_featured_retina/public/vitamin-angels-logo.jpg?itok=pfNCPLE0', + preview: + 'By improving access to vital nutrition, everyone gets an equal chance to grow, thrive, and prosper.', + description: + 'Our team of program experts collaborates with thousands of local organizations and national governments around the world, focusing efforts on reaching communities who are underserved. Vitamin Angels’ program partners are a local presence in these communities. As trusted organizations already hard at work, they connect millions of pregnant women and young children with our evidence-based nutrition interventions in addition to the health services they already provide.', + }, + { + name: 'Free Software Foundation', + website: 'https://www.fsf.org/', + ein: '04-2888848', + photo: 'https://www.gnu.org/graphics/logo-fsf.org.png', + preview: + 'The Free Software Foundation (FSF) is a nonprofit with a worldwide mission to promote computer user freedom.', + description: + 'As our society grows more dependent on computers, the software we run is of critical importance to securing the future of a free society. Free software is about having control over the technology we use in our homes, schools and businesses, where computers work for our individual and communal benefit, not for proprietary software companies or governments who might seek to restrict and monitor us. The Free Software Foundation exclusively uses free software to perform its work.The Free Software Foundation is working to secure freedom for computerusers by promoting the development and use of free (as in freedom) software and documentation—particularly the GNU operating system—and by campaigning against threats to computer user freedom like Digital Restrictions Management (DRM) and software patents.', + }, + { + name: 'Direct Relief', + website: 'https://www.directrelief.org/', + ein: '95-1831116', + photo: + 'https://www.ngoadvisor.net/wp-content/uploads/2016/02/DirectRelief_Logo_RGB-2-1920x576.png', + preview: + 'Direct Relief is a humanitarian aid organization, active in all 50 states and more than 80 countries, with a mission to improve the health and lives of people affected by poverty or emergencies – without regard to politics, religion, or ability to pay.', + description: + 'Nongovernmental, nonsectarian, and not-for-profit, Direct Relief relies entirely on private contributions to advance its mission and perform a wide range of functions.\n Included among them are identifying key local providers of health services; working to identify the unmet needs of people in the low-resource areas; mobilizing essential medicines, supplies, and equipment that are requested and appropriate for the circumstances; and managing the many details inherent in storing, transporting, and distributing such resources to organizations in the most efficient manner possible.', + }, + { + name: 'World Resources Institute', + website: 'https://www.wri.org/', + ein: '52-1257057', + photo: + 'https://www.americansecurityproject.org/wp-content/uploads/2016/11/WRI_logo_4c.png', + preview: + 'WRI is a global nonprofit organization that works with leaders in government, business and civil society to research, design, and carry out practical solutions that simultaneously improve people’s lives and ensure nature can thrive.', + description: + "Since its founding in 1982, WRI has been guided by its mission and core values which are integrated into all that we do. Our mission: To move human society to live in ways that protect Earth’s environment and its capacity to provide for the needs and aspirations of current and future generations. WRI relies on the generosity of our donors to drive outcomes that help the world to be a fairer, healthier and more sustainable place for people and the planet. We publish our financials annually to highlight our continued fiscal accountability. That's why WRI consistently receives top ratings from charity evaluators for our strong financial stewardship and commitment to transparency and accountability.", + }, + { + name: 'ProPublica', + website: 'https://www.propublica.org/', + ein: '14-2007220', + photo: + 'https://seekvectorlogo.com/wp-content/uploads/2018/09/propublica-vector-logo.png', + preview: + 'The mission: to expose abuses of power and betrayals of the public trust by government, business, and other institutions, using the moral force of investigative journalism to spur reform through the sustained spotlighting of wrongdoing.', + description: + 'ProPublica is an independent, nonprofit newsroom that produces investigative journalism with moral force. We dig deep into important issues, shining a light on abuses of power and betrayals of public trust — and we stick with those issues as long as it takes to hold power to account. With a team of more than 100 dedicated journalists, ProPublica covers a range of topics including government and politics, business, criminal justice, the environment, education, health care, immigration, and technology. We focus on stories with the potential to spur real-world impact. Among other positive changes, our reporting has contributed to the passage of new laws; reversals of harmful policies and practices; and accountability for leaders at local, state and national levels.', + }, + { + name: 'Dana-Farber Cancer Institute', + website: 'https://www.dana-farber.org/', + ein: '04-2263040', + photo: + 'https://www.danafarbermasterclass.com/assets/images/DFCI-logo-lens-stacked.png', + preview: + "For over 70 years, we've led the world by making life-changing breakthroughs in cancer research and patient care, providing the most advanced treatments available.", + description: + "Since its founding in 1947, Dana-Farber Cancer Institute in Boston, Massachusetts has been committed to providing adults and children with cancer with the best treatment available today while developing tomorrow's cures through cutting-edge research. Today, the Institute employs more than 5,000 staff, faculty, and clinicians supporting more than 640,000 annual outpatient visits, more than 1,000 hospital discharges per year, and has over 1,100 open clinical trials. Dana-Farber is internationally renowned for its equal commitment to cutting edge research and provision of excellent patient care. The deep expertise in these two areas uniquely positions Dana-Farber to develop, test, and gain FDA approval for new cancer therapies in its laboratories and clinical settings. Dana-Farber researchers have contributed to the development of 35 of 75 cancer drugs recently approved by the FDA for use in cancer patients.", + }, + { + name: 'Save The Children', + website: 'https://www.savethechildren.org/', + ein: '06-0726487', + photo: + 'https://www.thisisclapham.co.uk/wp-content/uploads/2016/08/savethechildren.png', + preview: + 'Through the decades, Save the Children has continued to work to save children’s lives, and that’s still what we do today.', + description: + "Our pioneering programs address children's unique needs, giving them a healthy start in life, the opportunity to learn and protection from harm. In the United States and around the world, our work creates lasting change for children, their families and communities – ultimately, transforming the future we all share.\nThis work is only made possible by the ongoing generosity of our donors, whose valuable support is used in the most cost-effective ways. It's important to note that all our work intersects – helping a boy or girl go to school also protects them from dangers such as child trafficking and early marriage. Keeping children healthy from disease or malnutrition means their parents are more likely to avoid costly treatment and be better able to provide for their family.\nWe don’t go into communities, carry out a project and then move on. We consult with children, their families, community leaders and local councils to understand all the issues or barriers, and then we develop programs that address these. We build trust so that our programs are successful and bring about real change.", + }, + { + name: 'World Central Kitchen Incorporated', + website: 'https://wck.org/', + ein: '27-3521132', + photo: + 'https://res.cloudinary.com/dktp1ybbx/image/upload/f_auto,fl_lossy,q_auto/v1560203222/organization/prod/924457/M0oxO9vaxO.png', + preview: + 'WCK is first to the frontlines, providing meals in response to humanitarian, climate, and community crises. We build resilient food systems with locally led solutions.', + description: + "WCK responds to natural disasters, man-made crises, and humanitarian emergencies around the world. We're a team of food first responders, mobilizing with the urgency of now to get meals to the people who need them most. Deploying our model of quick action, leveraging local resources, and adapting in real time, we know that a nourishing meal in a time of crisis is so much more than a plate of food—it's hope, it's dignity, and it's a sign that someone cares.", + }, + { + name: 'The Johns Hopkins Center for Health Security', + website: 'https://www.centerforhealthsecurity.org/', + ein: '', + photo: + 'https://www.centerforhealthsecurity.org/sebin/d/d/CHS.logo.horizontal.blue.png', + preview: + 'Our mission: to protect people’s health from epidemics and disasters and ensure that communities are resilient to major challenges.', + description: + 'The Center for Health Security undertakes a series of projects, collaborations, and initiatives to push forward progress on global health security, emerging infectious diseases and epidemics, medical and public health preparedness and response, deliberate biological threats, and opportunities and risks in the life sciences. We:\n- Conduct research and analysis on major domestic and international health security issues.\n- Engage with researchers, the policymaking community, and the private sector to make progress in the field.\n- Convene expert working groups, congressional seminars, scientific meetings, conferences, and tabletop exercises to stimulate new thinking and provoke action.\n- Educate a rising generation of scholars, practitioners, and policymakers.', + }, + { + name: 'ALLFED', + website: 'https://allfed.info/', + ein: '27-6601178', + preview: 'Feeding everyone no matter what.', + description: + 'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.', + }, +].map((charity) => { + const slug = charity.name.toLowerCase().replace(/\s/g, '-') + return { + ...charity, + id: slug, + slug, + } +}) diff --git a/common/txn.ts b/common/txn.ts new file mode 100644 index 00000000..8beea234 --- /dev/null +++ b/common/txn.ts @@ -0,0 +1,21 @@ +// A txn (pronounced "texan") respresents a payment between two ids on Manifold +// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) +export type Txn = { + id: string + createdTime: number + + fromId: string + fromType: SourceType + + toId: string + toType: SourceType + + amount: number + token: 'M$' // | 'USD' | MarketOutcome + + category: 'CHARITY' // | 'BET' | 'TIP' + // Human-readable description + description?: string +} + +export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' diff --git a/firestore.rules b/firestore.rules index 48214e3b..65783ba1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,5 +1,6 @@ rules_version = '2'; +// To pick the right project: `firebase projects:list`, then `firebase use ` // To deploy: `firebase deploy --only firestore:rules` service cloud.firestore { match /databases/{database}/documents { @@ -76,5 +77,9 @@ service cloud.firestore { allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId; allow delete: if request.auth.uid == userId; } + + match /txns/{txnId} { + allow read; + } } } diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index bdafbf98..e840b71a 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -46,6 +46,7 @@ export const backupDb = functions.pubsub 'comments', 'followers', 'answers', + 'txns', ], }) .then((responses) => { diff --git a/functions/src/index.ts b/functions/src/index.ts index 19a4a054..f8aa50e3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() // export * from './keep-awake' +export * from './transact' export * from './place-bet' export * from './resolve-market' export * from './stripe' diff --git a/functions/src/transact.ts b/functions/src/transact.ts new file mode 100644 index 00000000..04e58568 --- /dev/null +++ b/functions/src/transact.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from '../../common/user' +import { Txn } from '../../common/txn' +import { removeUndefinedProps } from '../../common/util/object' + +export const transact = functions + .runWith({ minInstances: 1 }) + .https.onCall(async (data: Omit, context) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { amount, fromType, fromId, toId, toType, description } = data + + if (fromType !== 'USER') + return { + status: 'error', + message: "From type is only implemented for type 'user'.", + } + + if (fromId !== userId) + return { + status: 'error', + message: 'Must be authenticated with userId equal to specified fromId.', + } + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const fromDoc = firestore.doc(`users/${userId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await transaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + transaction.update(toDoc, { balance: toUser.balance + amount }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + + const txn: Txn = removeUndefinedProps({ + id: newTxnDoc.id, + createdTime: Date.now(), + + fromId, + fromType, + toId, + toType, + + amount, + // TODO: Unhardcode once we have non-donation txns + token: 'M$', + category: 'CHARITY', + description, + }) + + transaction.create(newTxnDoc, txn) + transaction.update(fromDoc, { balance: fromUser.balance - amount }) + + return { status: 'success', txn } + }) + }) + +const firestore = admin.firestore() diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx new file mode 100644 index 00000000..e50a9fe7 --- /dev/null +++ b/web/components/charity/charity-card.tsx @@ -0,0 +1,34 @@ +import _ from 'lodash' +import Link from 'next/link' +import { Charity } from '../../../common/charity' +import { useCharityTxns } from '../../hooks/use-charity-txns' +import { Row } from '../layout/row' + +export function CharityCard(props: { charity: Charity }) { + const { name, slug, photo, preview, id } = props.charity + + const txns = useCharityTxns(id) + const raised = _.sumBy(txns, (txn) => txn.amount) + + return ( + +
+
+ {photo ? ( + + ) : ( +
+ )} +
+
+

{name}

+
{preview}
+ + ${Math.floor((raised ?? 0) / 100)} + raised + +
+
+ + ) +} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index fadff13c..a9cb49f0 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -3,8 +3,8 @@ import Link from 'next/link' import { HomeIcon, MenuAlt3Icon, + PresentationChartLineIcon, SearchIcon, - TableIcon, XIcon, } from '@heroicons/react/outline' import { Transition, Dialog } from '@headlessui/react' @@ -39,7 +39,10 @@ export function BottomNavBar() { {user !== null && ( - diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 576cea87..e3145498 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -5,9 +5,10 @@ import { SearchIcon, ChatIcon, BookOpenIcon, - TableIcon, DotsHorizontalIcon, CashIcon, + HeartIcon, + PresentationChartLineIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import _ from 'lodash' @@ -24,7 +25,8 @@ import { useHasCreatedContractToday } from '../../hooks/use-has-created-contract const navigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, - { name: 'Portfolio', href: '/portfolio', icon: TableIcon }, + { name: 'Portfolio', href: '/portfolio', icon: PresentationChartLineIcon }, + { name: 'Charity', href: '/charity', icon: HeartIcon }, ] const signedOutNavigation = [ @@ -34,6 +36,7 @@ const signedOutNavigation = [ ] const signedOutMobileNavigation = [ + { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Leaderboards', href: '/leaderboards', icon: CakeIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts new file mode 100644 index 00000000..5636e720 --- /dev/null +++ b/web/hooks/use-charity-txns.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { Txn } from '../../common/txn' +import { listenForCharityTxns } from '../lib/firebase/txns' + +export const useCharityTxns = (charityId: string) => { + const [txns, setTxns] = useState([]) + + useEffect(() => { + return listenForCharityTxns(charityId, setTxns) + }, [charityId]) + + return txns +} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 1c5522e7..a71c2752 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,5 +1,6 @@ import { httpsCallable } from 'firebase/functions' import { Fold } from '../../../common/fold' +import { Txn } from '../../../common/txn' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' import './init' @@ -15,6 +16,11 @@ export const createFold = cloudFunction< { status: 'error' | 'success'; message?: string; fold?: Fold } >('createFold') +export const transact = cloudFunction< + Omit, + { status: 'error' | 'success'; message?: string; txn?: Txn } +>('transact') + export const placeBet = cloudFunction('placeBet') export const sellBet = cloudFunction('sellBet') diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts new file mode 100644 index 00000000..8f9a6843 --- /dev/null +++ b/web/lib/firebase/txns.ts @@ -0,0 +1,23 @@ +import { collection, query, where, orderBy } from 'firebase/firestore' +import _ from 'lodash' +import { Txn } from '../../../common/txn' + +import { db } from './init' +import { listenForValues } from './utils' + +const txnCollection = collection(db, 'txns') + +const getCharityQuery = (charityId: string) => + query( + txnCollection, + where('toType', '==', 'CHARITY'), + where('toId', '==', charityId), + orderBy('createdTime', 'desc') + ) + +export function listenForCharityTxns( + charityId: string, + setTxns: (txns: Txn[]) => void +) { + return listenForValues(getCharityQuery(charityId), setTxns) +} diff --git a/web/package.json b/web/package.json index 84077a19..0e770f96 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/forms": "0.4.0", + "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.1", "@types/lodash": "4.14.178", "@types/node": "16.11.11", diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx new file mode 100644 index 00000000..e358f4e1 --- /dev/null +++ b/web/pages/charity/[charitySlug].tsx @@ -0,0 +1,212 @@ +import _ from 'lodash' +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' +import { Col } from '../../components/layout/col' +import { Row } from '../../components/layout/row' +import { Page } from '../../components/page' +import { Title } from '../../components/title' +import { BuyAmountInput } from '../../components/amount-input' +import { Spacer } from '../../components/layout/spacer' +import { User } from '../../../common/user' +import { useUser } from '../../hooks/use-user' +import { Linkify } from '../../components/linkify' +import { transact } from '../../lib/firebase/api-call' +import { charities, Charity } from '../../../common/charity' +import { useRouter } from 'next/router' +import Custom404 from '../404' +import { useCharityTxns } from '../../hooks/use-charity-txns' + +const manaToUSD = (mana: number) => + (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) + +export default function CharityPageWrapper() { + const router = useRouter() + const { charitySlug } = router.query as { charitySlug: string } + + const charity = charities.find((c) => c.slug === charitySlug?.toLowerCase()) + if (!router.isReady) return <> + if (!charity) { + return + } + return +} + +function CharityPage(props: { charity: Charity }) { + const { charity } = props + const { name, photo, description } = charity + + // TODO: why not just useUser inside Donation Box rather than passing in? + const user = useUser() + + const txns = useCharityTxns(charity.id) + const totalRaised = _.sumBy(txns, (txn) => txn.amount) + const fromYou = _.sumBy( + txns.filter((txn) => txn.fromId === user?.id), + (txn) => txn.amount + ) + const numSupporters = _.uniqBy(txns, (txn) => txn.fromId).length + + return ( + }> + + + + {/* TODO: donations over time chart */} + <Row className="justify-between"> + {photo && ( + <img + src={photo} + alt="" + className="w-40 rounded-2xl object-contain" + /> + )} + <Details + charity={charity} + totalRaised={totalRaised} + userDonated={fromYou} + numSupporters={numSupporters} + /> + </Row> + <h2 className="mt-7 mb-2 text-xl text-indigo-700">About</h2> + <Blurb text={description} /> + </Col> + </Col> + </Page> + ) +} + +function Blurb({ text }: { text: string }) { + const [open, setOpen] = useState(false) + + // Calculate whether the full blurb is already shown + const ref = useRef<HTMLDivElement>(null) + const [hideExpander, setHideExpander] = useState(false) + useEffect(() => { + if (ref.current) { + setHideExpander(ref.current.scrollHeight <= ref.current.clientHeight) + } + }, []) + + return ( + <> + <div + className={clsx(' text-gray-500', !open && 'line-clamp-5')} + ref={ref} + > + {text} + </div> + <button + onClick={() => setOpen(!open)} + className={clsx( + 'btn btn-link capitalize-none my-3 normal-case text-indigo-700', + hideExpander && 'hidden' + )} + > + {open ? 'Hide' : 'Read more'} + </button> + </> + ) +} + +function Details(props: { + charity: Charity + totalRaised: number + userDonated: number + numSupporters: number +}) { + const { charity, userDonated, numSupporters, totalRaised } = props + const { website } = charity + return ( + <Col className="gap-1 text-right"> + <div className="text-primary mb-2 text-4xl"> + {manaToUSD(totalRaised ?? 0)} raised + </div> + {userDonated > 0 && ( + <div className="text-primary text-xl"> + {manaToUSD(userDonated)} from you! + </div> + )} + {numSupporters > 0 && ( + <div className="text-gray-500">{numSupporters} supporters</div> + )} + <Linkify text={website} /> + </Col> + ) +} + +function DonationBox(props: { user?: User | null; charity: Charity }) { + const { user, charity } = props + const [amount, setAmount] = useState<number | undefined>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState<string | undefined>() + + const donateDisabled = isSubmitting || !amount || error + + const onSubmit: React.FormEventHandler = async (e) => { + if (!user || donateDisabled) return + + e.preventDefault() + setIsSubmitting(true) + setError(undefined) + await transact({ + amount, + fromId: user.id, + fromType: 'USER', + toId: charity.id, + toType: 'CHARITY', + token: 'M$', + category: 'CHARITY', + description: `${user.name} donated M$ ${amount} to ${charity.name}`, + }).catch((err) => console.log('Error', err)) + setIsSubmitting(false) + setAmount(undefined) + } + + return ( + <div className="rounded-lg bg-white py-6 px-8 shadow-lg"> + <div className="mb-6 text-2xl text-gray-700">Donate</div> + <form onSubmit={onSubmit}> + <label + className="mb-2 block text-sm text-gray-500" + htmlFor="donate-input" + > + Amount + </label> + <BuyAmountInput + inputClassName="w-full donate-input" + amount={amount} + onChange={setAmount} + error={error} + setError={setError} + /> + + <Col className="mt-3 w-full gap-3"> + <Row className="items-center justify-between text-sm"> + <span className="text-gray-500">Conversion</span> + <span> + {amount || 0} Mana + <span className="mx-2">→</span> + {manaToUSD(amount || 0)} + </span> + </Row> + {/* TODO: matching pool */} + </Col> + + <Spacer h={8} /> + + {user && ( + <button + type="submit" + className={clsx( + 'btn w-full', + donateDisabled ? 'btn-disabled' : 'btn-primary', + isSubmitting && 'loading' + )} + > + Donate + </button> + )} + </form> + </div> + ) +} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx new file mode 100644 index 00000000..c2b5a270 --- /dev/null +++ b/web/pages/charity/index.tsx @@ -0,0 +1,64 @@ +import _ from 'lodash' +import { useState, useMemo } from 'react' +import { charities as charityList } from '../../../common/charity' +import { CharityCard } from '../../components/charity/charity-card' +import { Col } from '../../components/layout/col' +import { Page } from '../../components/page' +import { Title } from '../../components/title' + +// TODO: Fetch amount raised. +const charities = charityList.map((charity) => ({ + ...charity, + raised: 4001, +})) + +export default function Charity() { + const [query, setQuery] = useState('') + const debouncedQuery = _.debounce(setQuery, 50) + + const filterCharities = useMemo( + () => charities.filter((charity) => charity.name.includes(query)), + [query] + ) + + return ( + <Page> + <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> + <Col className="max-w-xl gap-2"> + <Title className="!mt-0" text="Donate your M$ to charity!" /> + <div className="mb-6 text-gray-500"> + Throughout the month of May, every M$ 100 you contribute turns into + $1 USD to your chosen charity. We'll cover all processing fees! + </div> + + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search charities" + className="input input-bordered mb-6 w-full" + /> + </Col> + <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> + {filterCharities.map((charity) => ( + <div key={charity.name}> + <CharityCard charity={charity} /> + </div> + ))} + </div> + {filterCharities.length === 0 && ( + <div className="text-center text-gray-500"> + No charities match your search :( + </div> + )} + + <div className="mt-10 italic text-gray-500"> + Note: Manifold is not affiliated with any of these charities, other + than being fans of their work. + <br /> + As Manifold is a for-profit entity, your contributions will not be tax + deductible. + </div> + </Col> + </Page> + ) +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 5a87e424..31c0c533 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -22,6 +22,7 @@ module.exports = { plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), + require('@tailwindcss/line-clamp'), require('daisyui'), ], daisyui: { diff --git a/yarn.lock b/yarn.lock index 852ccf89..dfcb2a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,6 +906,11 @@ dependencies: mini-svg-data-uri "^1.2.3" +"@tailwindcss/line-clamp@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.3.1.tgz#4d8441b509b87ece84e94f28a4aa9998413ab849" + integrity sha512-pNr0T8LAc3TUx/gxCfQZRe9NB2dPEo/cedPHzUGIPxqDMhgjwNm6jYxww4W5l0zAsAddxr+XfZcqttGiFDgrGg== + "@tailwindcss/typography@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46"