From 7af0a31131c257835278113076b126c27b978887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=BCndig?= Date: Mon, 13 Nov 2023 20:11:12 +0100 Subject: [PATCH] feature(website): support one-time donations (#623) --- shared/locales/de/website-home.json | 5 +- shared/locales/de/website-me.json | 43 +++++- shared/locales/en/website-home.json | 6 +- shared/locales/en/website-me.json | 1 + ui/src/components/button.tsx | 13 +- ui/src/components/form.tsx | 13 +- ui/src/components/input.tsx | 2 +- ui/src/components/select.tsx | 4 +- ui/src/globals.css | 30 +---- .../(website)/(home)/section-1-form.tsx | 42 ++++-- .../[region]/(website)/(home)/section-1.tsx | 8 +- .../donate/success/link-google-form.tsx | 33 ----- .../(website)/donate/success/page.tsx | 31 ----- .../app/[lang]/[region]/(website)/layout.tsx | 4 +- .../[region]/(website)/login/login-form.tsx | 24 +++- .../[lang]/[region]/(website)/login/page.tsx | 34 ++--- .../[region]/(website)/me/layout-client.tsx | 16 +-- .../[lang]/[region]/(website)/me/layout.tsx | 2 +- .../app/[lang]/[region]/(website)/me/page.tsx | 16 +-- .../contributions-table.tsx | 2 +- .../me/{contributions => payments}/page.tsx | 2 +- .../[region]/(website)/me/security/page.tsx | 7 +- .../(website)/me/subscriptions/page.tsx | 2 +- .../(website)/me/user-context-provider.tsx | 16 +-- .../donate/individual/page.tsx | 6 +- .../src/app/[lang]/[region]/donate/layout.tsx | 12 ++ .../one-time/one-time-donation-form.tsx | 82 ++++++++++++ .../[lang]/[region]/donate/one-time/page.tsx | 30 +++++ .../donate/success/create-user-form.tsx | 52 ++++++-- .../[lang]/[region]/donate/success/page.tsx | 46 +++++++ .../donate/success/single-sign-on-form.tsx | 42 ++++++ .../api/stripe/checkout/new-payment/route.ts | 11 +- .../app/api/stripe/checkout/success/route.ts | 1 - website/src/app/context-providers.tsx | 4 +- website/src/app/globals.css | 19 +++ .../src/components/navbar/navbar-client.tsx | 122 ++++++++++-------- website/src/components/navbar/navbar.tsx | 9 +- .../src/components/ui/currency-selector.tsx | 13 +- website/src/i18n.ts | 2 +- 39 files changed, 551 insertions(+), 256 deletions(-) delete mode 100644 website/src/app/[lang]/[region]/(website)/donate/success/link-google-form.tsx delete mode 100644 website/src/app/[lang]/[region]/(website)/donate/success/page.tsx rename website/src/app/[lang]/[region]/(website)/me/{contributions => payments}/contributions-table.tsx (98%) rename website/src/app/[lang]/[region]/(website)/me/{contributions => payments}/page.tsx (93%) rename website/src/app/[lang]/[region]/{(website) => }/donate/individual/page.tsx (95%) create mode 100644 website/src/app/[lang]/[region]/donate/layout.tsx create mode 100644 website/src/app/[lang]/[region]/donate/one-time/one-time-donation-form.tsx create mode 100644 website/src/app/[lang]/[region]/donate/one-time/page.tsx rename website/src/app/[lang]/[region]/{(website) => }/donate/success/create-user-form.tsx (53%) create mode 100644 website/src/app/[lang]/[region]/donate/success/page.tsx create mode 100644 website/src/app/[lang]/[region]/donate/success/single-sign-on-form.tsx diff --git a/shared/locales/de/website-home.json b/shared/locales/de/website-home.json index 91dcc1586..cad6796c1 100644 --- a/shared/locales/de/website-home.json +++ b/shared/locales/de/website-home.json @@ -4,7 +4,10 @@ "title-2": "aus der Armut helfen?", "title-3": "", "income-text": "Gib dein monatliches Einkommen ein und schau, was du bewirken kannst.", - "button-text": "Beitrag berechnen" + "button-text": "Beitrag berechnen", + "privacy-commitment": "Wir verpflichten uns zum Datenschutz", + "tax-deductible": "Dein Beitrag ist in der Schweiz steuerbefreit", + "one-time-donation": "Make a one-time donation" }, "section-2": { "title-1": "Was würde sich in deinem Alltag ändern, ", diff --git a/shared/locales/de/website-me.json b/shared/locales/de/website-me.json index d265a9648..4e0289f7c 100644 --- a/shared/locales/de/website-me.json +++ b/shared/locales/de/website-me.json @@ -1,12 +1,44 @@ { - "tabs": { - "contact-details": "Kontaktangaben", - "contributions": "Zuwendungen" + "sections": { + "account": { + "title": "My Account", + "personal-info": "Personal Info", + "security": "Security" + }, + "contributions": { + "title": "My Contributions", + "payments": "Payments", + "subscriptions": "Subscriptions" + } }, "contributions": { - "amount": "Betrag", + "amount": "Amount", + "date": "Date", + "source": "Source", + "total": "Total", "amount-currency": "{{ amount, currency }}", - "date": "Datum" + "sources": { + "benevity": "Benevity", + "cash": "Cash", + "stripe": "Stripe", + "wire-transfer": "Wire Transfer" + } + }, + "subscriptions": { + "amount": "Amount", + "date": "Date", + "source": "Source", + "total": "Total", + "amount-currency": "{{ amount, currency }}", + "interval": "Interval", + "interval-1": "Monatlich", + "interval-3": "Quartalsweise", + "interval-12": "Jährlich", + "status": { + "active": "Aktiv", + "canceled": "Beendet", + "paused": "Pausiert" + } }, "login": { "title": "Melde dich an", @@ -14,6 +46,7 @@ "invalid-email": "Ungültige E-Mail-Adresse", "password": "Passwort", "forgot-password": "Passwort vergessen?", + "sign-in-with-google": "Mit Google anmelden", "required-field": "Dieses Feld ist erforderlich", "submit-button": "Anmelden", "unknown-user": "Es gibt keinen Benutzer mit dieser E-Mail-Adresse", diff --git a/shared/locales/en/website-home.json b/shared/locales/en/website-home.json index e91cd7b5b..ea498d7d1 100644 --- a/shared/locales/en/website-home.json +++ b/shared/locales/en/website-home.json @@ -4,7 +4,11 @@ "title-2": "lift out of poverty ", "title-3": "with 1% of your income?", "income-text": "Enter your monthly income and calculate your impact.", - "button-text": "Show my Impact" + "amount": "Amount", + "button-text": "Show my Impact", + "privacy-commitment": "Our commitment to privacy", + "tax-deductible": "Contributions are tax-deductible in Switzerland", + "one-time-donation": "Make a one-time donation" }, "section-2": { "title-1": "What would change ", diff --git a/shared/locales/en/website-me.json b/shared/locales/en/website-me.json index 3ddf24201..0d0cbd0ae 100644 --- a/shared/locales/en/website-me.json +++ b/shared/locales/en/website-me.json @@ -46,6 +46,7 @@ "invalid-email": "Invalid email address", "password": "Password", "forgot-password": "Forgot password?", + "sign-in-with-google": "Sign in with Google", "required-field": "This field is required", "submit-button": "Sign in", "unknown-user": "This user does not exist", diff --git a/ui/src/components/button.tsx b/ui/src/components/button.tsx index e6e9d7e00..1f758ab60 100644 --- a/ui/src/components/button.tsx +++ b/ui/src/components/button.tsx @@ -1,3 +1,4 @@ +import { IconType } from '@icons-pack/react-simple-icons/types'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; @@ -18,7 +19,7 @@ const buttonVariants = cva( size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', - lg: 'h-16 rounded-md px-8 font-semibold', + lg: 'h-16 rounded-md px-8 font-semibold text-lg', icon: 'h-10 w-10', }, }, @@ -33,12 +34,18 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + Icon?: IconType; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, Icon, children, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; - return ; + return ( + + {Icon && } + {children} + + ); }, ); Button.displayName = 'Button'; diff --git a/ui/src/components/form.tsx b/ui/src/components/form.tsx index 3da7d006d..8b6af8aa8 100644 --- a/ui/src/components/form.tsx +++ b/ui/src/components/form.tsx @@ -4,7 +4,6 @@ import * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'; - import { cn } from '../lib/utils'; import { Label } from './label'; @@ -67,7 +66,7 @@ const FormItem = React.forwardRef -
+
); }, @@ -120,7 +119,15 @@ const FormMessage = React.forwardRef +

{body}

); diff --git a/ui/src/components/input.tsx b/ui/src/components/input.tsx index 436f8909a..2a72d1640 100644 --- a/ui/src/components/input.tsx +++ b/ui/src/components/input.tsx @@ -9,7 +9,7 @@ const Input = React.forwardRef(({ className, type, +
{translations.text}
- -
+ +
( - + )} /> - +
+ + + {translations.privacyCommitment} + + {region === 'ch' && ( +
+ + {translations.taxDeductible} +
+ )} +
+
+ + + +
); } diff --git a/website/src/app/[lang]/[region]/(website)/(home)/section-1.tsx b/website/src/app/[lang]/[region]/(website)/(home)/section-1.tsx index 4b9762961..62c6ae2a0 100644 --- a/website/src/app/[lang]/[region]/(website)/(home)/section-1.tsx +++ b/website/src/app/[lang]/[region]/(website)/(home)/section-1.tsx @@ -12,7 +12,7 @@ export default async function Section1({ params }: DefaultPageProps) { return (
@@ -25,10 +25,16 @@ export default async function Section1({ params }: DefaultPageProps) {
diff --git a/website/src/app/[lang]/[region]/(website)/donate/success/link-google-form.tsx b/website/src/app/[lang]/[region]/(website)/donate/success/link-google-form.tsx deleted file mode 100644 index 14b9cf16d..000000000 --- a/website/src/app/[lang]/[region]/(website)/donate/success/link-google-form.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { Button } from '@socialincome/ui'; -import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; -import { useAuth } from 'reactfire'; - -type LinkGoogleFormProps = { - checkoutSessionId: string; -}; - -export function LinkGoogleForm({ checkoutSessionId }: LinkGoogleFormProps) { - const auth = useAuth(); - - const onClick2 = async () => { - const provider = new GoogleAuthProvider(); - - signInWithPopup(auth, provider) - .then(async (result) => { - const user = result.user; - await fetch(`/api/stripe/checkout/success?stripeCheckoutSessionId=${checkoutSessionId}&userId=${user.uid}`); - }) - .catch((error) => { - console.log(error); - }); - }; - return ( -
- -
- ); -} diff --git a/website/src/app/[lang]/[region]/(website)/donate/success/page.tsx b/website/src/app/[lang]/[region]/(website)/donate/success/page.tsx deleted file mode 100644 index 9a0013f9a..000000000 --- a/website/src/app/[lang]/[region]/(website)/donate/success/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { DefaultPageProps } from '@/app/[lang]/[region]'; -import { initializeStripe } from '@socialincome/shared/src/stripe'; -import { BaseContainer, Typography } from '@socialincome/ui'; -import { CreateUserForm } from './create-user-form'; -import { LinkGoogleForm } from './link-google-form'; - -export default async function Page({ searchParams }: DefaultPageProps) { - const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); - const checkoutSession = await stripe.checkout.sessions.retrieve(searchParams.stripeCheckoutSessionId); - - return ( - -
- - Thank you - - - -
-
- ); -} diff --git a/website/src/app/[lang]/[region]/(website)/layout.tsx b/website/src/app/[lang]/[region]/(website)/layout.tsx index a994e274b..c33b9e48d 100644 --- a/website/src/app/[lang]/[region]/(website)/layout.tsx +++ b/website/src/app/[lang]/[region]/(website)/layout.tsx @@ -9,9 +9,9 @@ export const generateStaticParams = () => export default function Layout({ children, params }: PropsWithChildren) { return ( -
+
-
{children}
+
{children}
); diff --git a/website/src/app/[lang]/[region]/(website)/login/login-form.tsx b/website/src/app/[lang]/[region]/(website)/login/login-form.tsx index 8858cf8c8..b4b241a73 100644 --- a/website/src/app/[lang]/[region]/(website)/login/login-form.tsx +++ b/website/src/app/[lang]/[region]/(website)/login/login-form.tsx @@ -5,7 +5,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { SiGoogle } from '@icons-pack/react-simple-icons'; import { Button, Form, FormControl, FormField, FormItem, FormMessage, Input, Typography } from '@socialincome/ui'; import { FirebaseError } from 'firebase/app'; -import { browserSessionPersistence, signInWithEmailAndPassword } from 'firebase/auth'; +import { + GoogleAuthProvider, + browserSessionPersistence, + signInWithEmailAndPassword, + signInWithPopup, +} from 'firebase/auth'; import { useRouter } from 'next/navigation'; import { useCallback } from 'react'; import { useForm } from 'react-hook-form'; @@ -20,6 +25,7 @@ type LoginFormProps = { password: string; forgotPassword: string; submitButton: string; + signInWithGoogle: string; // Errors requiredField: string; @@ -59,6 +65,17 @@ export default function LoginForm({ lang, region, translations }: LoginFormProps [auth, lang, region, router, translations.wrongPassword, translations.unknownUser], ); + const onGoogleSignIn = () => { + const provider = new GoogleAuthProvider(); + signInWithPopup(auth, provider) + .then(async () => { + router.push(`/${lang}/${region}/me`); + }) + .catch((error) => { + console.log(error); + }); + }; + return (
@@ -96,9 +113,8 @@ export default function LoginForm({ lang, region, translations }: LoginFormProps
-
diff --git a/website/src/app/[lang]/[region]/(website)/login/page.tsx b/website/src/app/[lang]/[region]/(website)/login/page.tsx index 7596e4404..58098014b 100644 --- a/website/src/app/[lang]/[region]/(website)/login/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/login/page.tsx @@ -1,25 +1,29 @@ import { DefaultPageProps } from '@/app/[lang]/[region]'; import LoginForm from '@/app/[lang]/[region]/(website)/login/login-form'; import { Translator } from '@socialincome/shared/src/utils/i18n'; +import { BaseContainer } from '@socialincome/ui'; export default async function Page({ params }: DefaultPageProps) { const translator = await Translator.getInstance({ language: params.lang, namespaces: ['website-me'] }); return ( - + + + ); } diff --git a/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx b/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx index e118069aa..60459229d 100644 --- a/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx @@ -50,6 +50,13 @@ export function LayoutClient({ params, translations, children }: PropsWithChildr const navigationMenu = (
    + {translations.contributionsTitle} + + {translations.payments} + + + {translations.subscriptions} + {translations.accountTitle} {translations.personalInfo} @@ -57,13 +64,6 @@ export function LayoutClient({ params, translations, children }: PropsWithChildr {translations.security} - {translations.contributionsTitle} - - {translations.payments} - - - {translations.subscriptions} -
); @@ -75,7 +75,7 @@ export function LayoutClient({ params, translations, children }: PropsWithChildr case `/${params.lang}/${params.region}/me/security`: title = translations.security; break; - case `/${params.lang}/${params.region}/me/contributions`: + case `/${params.lang}/${params.region}/me/payments`: title = translations.payments; break; case `/${params.lang}/${params.region}/me/subscriptions`: diff --git a/website/src/app/[lang]/[region]/(website)/me/layout.tsx b/website/src/app/[lang]/[region]/(website)/me/layout.tsx index ed0b822bb..6dd5912cd 100644 --- a/website/src/app/[lang]/[region]/(website)/me/layout.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/layout.tsx @@ -9,7 +9,7 @@ export default async function Layout({ children, params }: PropsWithChildren + { - if (authUserStatus === 'success' && authUser === null) { - redirect('../login'); - } else { - redirect('./me/contributions'); - } - }, [authUser, authUserStatus]); +export default async function Page() { + redirect('./me/payments'); } diff --git a/website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx b/website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx similarity index 98% rename from website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx rename to website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx index be95e359f..25cf1843e 100644 --- a/website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx @@ -83,7 +83,7 @@ export function ContributionsTable({ lang, translations }: ContributionsTablePro {translator?.t('contributions.amount-currency', { context: { amount: _.sum(contributions?.docs.map((contribution) => contribution.get('amount'))), - currency: contributions?.docs[0].get('currency'), + currency: contributions?.docs.at(0)?.get('currency'), locale: lang, }, })} diff --git a/website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx b/website/src/app/[lang]/[region]/(website)/me/payments/page.tsx similarity index 93% rename from website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx rename to website/src/app/[lang]/[region]/(website)/me/payments/page.tsx index 5e5eca6bb..dd2e0a8f9 100644 --- a/website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/payments/page.tsx @@ -1,5 +1,5 @@ import { DefaultPageProps } from '@/app/[lang]/[region]'; -import { ContributionsTable } from '@/app/[lang]/[region]/(website)/me/contributions/contributions-table'; +import { ContributionsTable } from '@/app/[lang]/[region]/(website)/me/payments/contributions-table'; import { Translator } from '@socialincome/shared/src/utils/i18n'; export default async function Page({ params }: DefaultPageProps) { diff --git a/website/src/app/[lang]/[region]/(website)/me/security/page.tsx b/website/src/app/[lang]/[region]/(website)/me/security/page.tsx index 0cc1ac8ba..30ae01dd8 100644 --- a/website/src/app/[lang]/[region]/(website)/me/security/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/security/page.tsx @@ -9,12 +9,17 @@ export default function Page() { const router = useRouter(); const auth = useAuth(); + const onSignOut = async () => { + await signOut(auth); + router.push('/'); + }; + return (
Reset password - +
); } diff --git a/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx b/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx index 8e43d820a..63d5b3249 100644 --- a/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx @@ -2,8 +2,8 @@ import { DefaultPageProps } from '@/app/[lang]/[region]'; import { BillingPortalButton } from '@/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button'; import { SubscriptionsTable } from '@/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table'; +// TODO: i18n export default function Page({ params }: DefaultPageProps) { - // TODO: i18n return (
useContext(UserContext); export function UserContextProvider({ children }: PropsWithChildren) { const firestore = useFirestore(); - const { status: authUserStatus, data: authUser } = useUser(); - - useEffect(() => { - if (authUserStatus === 'success' && authUser === null) { - redirect('../login'); - } - }, [authUserStatus, authUser]); + const { data: authUser } = useUser(); const { data: user, refetch } = useQuery({ queryKey: ['UserContextProvider', authUser?.uid, firestore], @@ -40,7 +34,13 @@ export function UserContextProvider({ children }: PropsWithChildren) { staleTime: 1000 * 60 * 60, // 1 hour }); + useEffect(() => { + if (user === null) { + redirect('../login'); + } + }, [user]); + if (user) { return {children}; - } else return null; + } } diff --git a/website/src/app/[lang]/[region]/(website)/donate/individual/page.tsx b/website/src/app/[lang]/[region]/donate/individual/page.tsx similarity index 95% rename from website/src/app/[lang]/[region]/(website)/donate/individual/page.tsx rename to website/src/app/[lang]/[region]/donate/individual/page.tsx index d3d8fd70b..f9786df9d 100644 --- a/website/src/app/[lang]/[region]/(website)/donate/individual/page.tsx +++ b/website/src/app/[lang]/[region]/donate/individual/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { DefaultPageProps } from '@/app/[lang]/[region]'; -import { CreateSubscriptionData } from '@/app/api/stripe/checkout/new-payment/route'; +import { CreatePaymentData } from '@/app/api/stripe/checkout/new-payment/route'; import { CheckCircleIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -83,7 +83,7 @@ export default function Page({ params, searchParams }: DefaultPageProps) { const onSubmit = async (values: FormSchema) => { const authToken = await authUser?.getIdToken(true); - const data: CreateSubscriptionData = { + const data: CreatePaymentData = { amount: values.amount * 100, // The amount is in cents, so we need to multiply by 100 to get the correct amount. intervalCount: Number(values.intervalCount), successUrl: `${window.location.origin}/${params.lang}/${params.region}/donate/success?stripeCheckoutSessionId={CHECKOUT_SESSION_ID}`, @@ -101,7 +101,7 @@ export default function Page({ params, searchParams }: DefaultPageProps) { }; return ( - + How would you like to pay? diff --git a/website/src/app/[lang]/[region]/donate/layout.tsx b/website/src/app/[lang]/[region]/donate/layout.tsx new file mode 100644 index 000000000..0d0946478 --- /dev/null +++ b/website/src/app/[lang]/[region]/donate/layout.tsx @@ -0,0 +1,12 @@ +import { DefaultLayoutProps } from '@/app/[lang]/[region]'; +import Navbar from '@/components/navbar/navbar'; +import { PropsWithChildren } from 'react'; + +export default function Layout({ children, params: { lang, region } }: PropsWithChildren) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/website/src/app/[lang]/[region]/donate/one-time/one-time-donation-form.tsx b/website/src/app/[lang]/[region]/donate/one-time/one-time-donation-form.tsx new file mode 100644 index 000000000..df7400ce5 --- /dev/null +++ b/website/src/app/[lang]/[region]/donate/one-time/one-time-donation-form.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { DefaultParams } from '@/app/[lang]/[region]'; +import { CreatePaymentData } from '@/app/api/stripe/checkout/new-payment/route'; +import { useI18n } from '@/app/context-providers'; +import { CurrencySelector } from '@/components/ui/currency-selector'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Form, FormControl, FormField, FormItem, Input } from '@socialincome/ui'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { useUser } from 'reactfire'; +import Stripe from 'stripe'; +import * as z from 'zod'; + +type DonationFormProps = { + translations: { + submit: string; + currency: string; + }; +} & DefaultParams; + +export default function OneTimeDonationForm({ translations, lang, region }: DonationFormProps) { + const router = useRouter(); + const { data: authUser } = useUser(); + const { currency } = useI18n(); + + const formSchema = z.object({ + amount: z.coerce.number().min(1), + }); + + type FormSchema = z.infer; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { amount: '' as any }, + }); + + const onSubmit = async (values: FormSchema) => { + const authToken = await authUser?.getIdToken(true); + const data: CreatePaymentData = { + amount: values.amount * 100, // The amount is in cents, so we need to multiply by 100 to get the correct amount. + currency: currency, + successUrl: `${window.location.origin}/${lang}/${region}/donate/success?stripeCheckoutSessionId={CHECKOUT_SESSION_ID}`, + recurring: false, + firebaseAuthToken: authToken, + }; + + const response = await fetch('/api/stripe/checkout/new-payment', { + method: 'POST', + body: JSON.stringify(data), + }); + const { url } = (await response.json()) as Stripe.Response; + + // This sends the user to stripe.com where payment is completed + if (url) router.push(url); + }; + + return ( +
+
+ +
+ ( + + + + + + )} + /> + +
+ +
+ +
+ ); +} diff --git a/website/src/app/[lang]/[region]/donate/one-time/page.tsx b/website/src/app/[lang]/[region]/donate/one-time/page.tsx new file mode 100644 index 000000000..39f46c271 --- /dev/null +++ b/website/src/app/[lang]/[region]/donate/one-time/page.tsx @@ -0,0 +1,30 @@ +import { DefaultPageProps } from '@/app/[lang]/[region]'; +import OneTimeDonationForm from '@/app/[lang]/[region]/donate/one-time/one-time-donation-form'; +import { BaseContainer, Typography } from '@socialincome/ui'; + +// TODO: i18n +export default function Page({ params }: DefaultPageProps) { + return ( + +
+ + Your Donation + + + Make a one-time donation to Social Income + + +
+ +
+
+
+ ); +} diff --git a/website/src/app/[lang]/[region]/(website)/donate/success/create-user-form.tsx b/website/src/app/[lang]/[region]/donate/success/create-user-form.tsx similarity index 53% rename from website/src/app/[lang]/[region]/(website)/donate/success/create-user-form.tsx rename to website/src/app/[lang]/[region]/donate/success/create-user-form.tsx index 9d9665692..0481783b4 100644 --- a/website/src/app/[lang]/[region]/(website)/donate/success/create-user-form.tsx +++ b/website/src/app/[lang]/[region]/donate/success/create-user-form.tsx @@ -11,34 +11,44 @@ import * as z from 'zod'; type CreateUserFormProps = { email: string; checkoutSessionId: string; + onSuccessURL: string; translations: { title: string; + email: string; password: string; + passwordValidation: string; submitButton: string; invalidEmail: string; }; }; -export function CreateUserForm({ checkoutSessionId, email, translations }: CreateUserFormProps) { +export function CreateUserForm({ checkoutSessionId, email, onSuccessURL, translations }: CreateUserFormProps) { const router = useRouter(); const auth = useAuth(); - const formSchema = z.object({ - password: z.string(), - }); + const formSchema = z + .object({ + email: z.string().email({ message: translations.invalidEmail }), + password: z.string().min(8), + passwordValidation: z.string().min(8), + }) + .refine((data) => data.password === data.passwordValidation, { + message: 'Passwords do not match', + path: ['passwordValidation'], + }); type FormSchema = z.infer; const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: { password: '' }, + defaultValues: { email: email, password: '', passwordValidation: '' }, }); - const onSubmit = async () => { - createUserWithEmailAndPassword(auth, email, 'hallotest') + const onSubmit = async (values: FormSchema) => { + createUserWithEmailAndPassword(auth, email, values.password) .then(async (userCredential) => { const user = userCredential.user; await fetch(`/api/stripe/checkout/success?stripeCheckoutSessionId=${checkoutSessionId}&userId=${user.uid}`); - router.push('/me'); + router.push(onSuccessURL); }) .catch((error) => { console.log(error); @@ -48,9 +58,21 @@ export function CreateUserForm({ checkoutSessionId, email, translations }: Creat return (
- + {translations.title} + ( + + + + + + + )} + /> )} /> + ( + + + + + + + )} + /> diff --git a/website/src/app/[lang]/[region]/donate/success/page.tsx b/website/src/app/[lang]/[region]/donate/success/page.tsx new file mode 100644 index 000000000..0db083523 --- /dev/null +++ b/website/src/app/[lang]/[region]/donate/success/page.tsx @@ -0,0 +1,46 @@ +import { DefaultPageProps } from '@/app/[lang]/[region]'; +import { firestoreAdmin } from '@/firebase-admin'; +import { initializeStripe } from '@socialincome/shared/src/stripe'; +import { USER_FIRESTORE_PATH, User } from '@socialincome/shared/src/types/user'; +import { BaseContainer, Typography } from '@socialincome/ui'; +import { redirect } from 'next/navigation'; +import { CreateUserForm } from './create-user-form'; +import { SingleSignOnForm } from './single-sign-on-form'; + +// TODO: i18n +export default async function Page({ params: { lang, region }, searchParams }: DefaultPageProps) { + const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); + const checkoutSession = await stripe.checkout.sessions.retrieve(searchParams.stripeCheckoutSessionId); + const onSuccessURL = `/${lang}/${region}/me/personal-info`; + + const userDoc = await firestoreAdmin.findFirst(USER_FIRESTORE_PATH, (q) => + q.where('stripe_customer_id', '==', checkoutSession.customer), + ); + if (userDoc) redirect(`/${lang}/${region}/me/payments`); + + return ( + +
+ + Thank you for your Donation + +
+ + +
+
+
+ ); +} diff --git a/website/src/app/[lang]/[region]/donate/success/single-sign-on-form.tsx b/website/src/app/[lang]/[region]/donate/success/single-sign-on-form.tsx new file mode 100644 index 000000000..11d261a2d --- /dev/null +++ b/website/src/app/[lang]/[region]/donate/success/single-sign-on-form.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { SiGoogle } from '@icons-pack/react-simple-icons'; +import { Button, Typography } from '@socialincome/ui'; +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; +import { useRouter } from 'next/navigation'; +import { useAuth } from 'reactfire'; + +type LinkGoogleFormProps = { + checkoutSessionId: string; + onSuccessURL: string; +}; + +// TODO: i18n +export function SingleSignOnForm({ checkoutSessionId, onSuccessURL }: LinkGoogleFormProps) { + const auth = useAuth(); + const router = useRouter(); + + const onGoogleSignUp = async () => { + const provider = new GoogleAuthProvider(); + + signInWithPopup(auth, provider) + .then(async (result) => { + const user = result.user; + await fetch(`/api/stripe/checkout/success?stripeCheckoutSessionId=${checkoutSessionId}&userId=${user.uid}`); + router.push(onSuccessURL); + }) + .catch((error) => { + console.log(error); + }); + }; + return ( +
+ + or... + + +
+ ); +} diff --git a/website/src/app/api/stripe/checkout/new-payment/route.ts b/website/src/app/api/stripe/checkout/new-payment/route.ts index 09ac2fa28..78765622f 100644 --- a/website/src/app/api/stripe/checkout/new-payment/route.ts +++ b/website/src/app/api/stripe/checkout/new-payment/route.ts @@ -1,17 +1,18 @@ import { getUserDocFromAuthToken } from '@/firebase-admin'; +import { WebsiteCurrency } from '@/i18n'; import { initializeStripe } from '@socialincome/shared/src/stripe'; import { NextResponse } from 'next/server'; -export type CreateSubscriptionData = { +export type CreatePaymentData = { amount: number; // in the lowest currency unit, e.g. cents successUrl: string; recurring?: boolean; - currency?: string; + currency?: WebsiteCurrency; intervalCount?: number; firebaseAuthToken?: string; }; -type CreateSubscriptionRequest = { json(): Promise } & Request; +type CreateSubscriptionRequest = { json(): Promise } & Request; export async function POST(request: CreateSubscriptionRequest) { const { @@ -24,6 +25,7 @@ export async function POST(request: CreateSubscriptionRequest) { } = await request.json(); const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); + const customerId = userDoc?.get('stripe_customer_id'); const price = await stripe.prices.create({ active: true, unit_amount: amount, @@ -34,7 +36,8 @@ export async function POST(request: CreateSubscriptionRequest) { const session = await stripe.checkout.sessions.create({ mode: recurring ? 'subscription' : 'payment', payment_method_types: ['card'], - customer: userDoc?.get('stripe_customer_id'), + customer: customerId, + customer_creation: customerId ? undefined : 'always', line_items: [ { price: price.id, diff --git a/website/src/app/api/stripe/checkout/success/route.ts b/website/src/app/api/stripe/checkout/success/route.ts index 7e1cc22fb..5537786dd 100644 --- a/website/src/app/api/stripe/checkout/success/route.ts +++ b/website/src/app/api/stripe/checkout/success/route.ts @@ -8,7 +8,6 @@ export async function GET(request: Request) { if (!stripeCheckoutSessionId || !userId) { return new Response(null, { status: 400, statusText: 'Missing stripeCheckoutSessionId or userId' }); } - const stripeEventHandler = new StripeEventHandler(process.env.STRIPE_SECRET_KEY!, firestoreAdmin); await stripeEventHandler.handleCheckoutSessionCompletedEvent(stripeCheckoutSessionId, userId); return new Response(null, { status: 200 }); diff --git a/website/src/app/context-providers.tsx b/website/src/app/context-providers.tsx index 671172f93..c3908394a 100644 --- a/website/src/app/context-providers.tsx +++ b/website/src/app/context-providers.tsx @@ -143,7 +143,7 @@ function I18nProvider({ children }: PropsWithChildren) { urlSegments[1] = language; router.push(urlSegments.join('/')); } - }, [language, router]); + }, [language, router, setLanguage]); useEffect(() => { const urlSegments = window.location.pathname.split('/'); @@ -154,7 +154,7 @@ function I18nProvider({ children }: PropsWithChildren) { urlSegments[2] = region; router.push(urlSegments.join('/')); } - }, [region, router]); + }, [region, router, setRegion]); return ( +