Skip to content

Commit

Permalink
Merge pull request #380 from ungdev/dev
Browse files Browse the repository at this point in the history
Shop 2024
  • Loading branch information
DevNono authored Sep 13, 2024
2 parents d302bc6 + 8b26ca2 commit 7b32183
Show file tree
Hide file tree
Showing 42 changed files with 633 additions and 168 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ NEXT_PUBLIC_UPLOADS_URL=https://arena.dev.uttnetgroup.fr/uploads/files
# Google credentials
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=
NEXT_PUBLIC_GOOGLE_VERIFICATION=

# Stripe
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2019-2023 UNG
Copyright (c) 2019-2024 UNG

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"dependencies": {
"@react-spring/web": "^9.7.4",
"@reduxjs/toolkit": "^2.2.7",
"@stripe/react-stripe-js": "^2.8.0",
"@stripe/stripe-js": "^4.4.0",
"modern-normalize": "^3.0.0",
"next": "^14.2.7",
"qr-scanner": "^1.4.2",
Expand Down
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file modified public/favicon.ico
Binary file not shown.
3 changes: 2 additions & 1 deletion src/app/(dashboard)/admin/shop/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AdminItem } from '@/types';
import { useEffect, useRef, useState } from 'react';
import styles from './style.module.scss';
import { fetchAdminItems, reorderItems } from '@/modules/admin';
import { getItemImageLink } from '@/utils/uploadLink';

const Shop = () => {
const shopItems = useAppSelector((state) => state.admin.items);
Expand All @@ -27,7 +28,7 @@ const Shop = () => {
shopItems?.map((item, index) => (
<Square
key={index}
imgSrc={item.image ? '/images/' + item.image : undefined}
imgSrc={item.image ? getItemImageLink(item.id) : undefined}
alt={item.name}
onClick={(e) => {
if ((e!.target as ChildNode).parentElement?.parentElement?.classList.contains('dragging')) return;
Expand Down
2 changes: 1 addition & 1 deletion src/app/(dashboard)/dashboard/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const Account = () => {
const data = window.URL.createObjectURL(response);
const link = document.createElement('a');
link.href = data;
link.download = 'Billet UTT Arena 2023.pdf';
link.download = 'Billet UTT Arena 2024.pdf';
link.click();
};

Expand Down
49 changes: 49 additions & 0 deletions src/app/(dashboard)/dashboard/payment/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'react-toastify';
import { useStripe } from '@stripe/react-stripe-js';
import { PaymentIntentResult } from '@stripe/stripe-js';
import { Loader } from '@/components/UI';
import styles from '../style.module.scss';

const Payment = () => {
const router = useRouter();
const search = useSearchParams();
const clientSecret = search.get('payment_intent_client_secret');
const stripe = useStripe();

useEffect(() => {
if (!stripe || !clientSecret) {
return;
}

stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }: PaymentIntentResult) => {
if (!paymentIntent) {
return;
}

if (paymentIntent.status === 'succeeded') {
toast.success('Paiement effectué avec succès');
} else if (paymentIntent.status === 'requires_payment_method') {
toast.error('Le paiement a échoué. Veuillez réessayer.');
} else if (paymentIntent.status === 'processing') {
toast.error(
'Le paiement est en cours de traitement. Vous recevrez un email de confirmation une fois le paiement effectué.',
);
} else {
toast.error("Une erreur inattendue s'est produite lors du paiement.");
}

router.push('/dashboard');
});
}, [stripe, clientSecret]);

return (
<div className={styles.loader}>
<Loader />
</div>
);
};

export default Payment;
35 changes: 35 additions & 0 deletions src/app/(dashboard)/dashboard/payment/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { Elements } from '@stripe/react-stripe-js';
import { Appearance, StripeElementsOptions } from '@stripe/stripe-js';

import variables from '@/variables.module.scss';
import { useSearchParams } from 'next/navigation';
import { stripe } from '@/utils/stripe';

const stripePromise = stripe;
const PaymentLayout = ({ children }: { children: React.ReactNode }) => {
const search = useSearchParams();
const stripeToken = search.get('stripeToken') ?? search.get('payment_intent_client_secret');

const appearance = {
theme: 'stripe',
variables: {
colorPrimary: variables.primaryColor,
colorBackground: variables.primaryBackground,
colorText: variables.lightColor,
},
} satisfies Appearance;
const options = {
clientSecret: stripeToken,
appearance,
} as StripeElementsOptions;

return (
<Elements stripe={stripePromise} options={options}>
{children}
</Elements>
);
};

export default PaymentLayout;
199 changes: 172 additions & 27 deletions src/app/(dashboard)/dashboard/payment/page.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,184 @@
'use client';
import { useEffect } from 'react';
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'react-toastify';

interface SearchParams extends ReadonlyURLSearchParams {
type?: string;
error?: string;
}
import { Button, Loader, Title } from '@/components/UI';
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { StripePaymentElementOptions } from '@stripe/stripe-js';
import { useEffect, useState, useMemo } from 'react';
import styles from './style.module.scss';
import Cart from '@/components/dashboard/Cart';
import { useAppDispatch, useAppSelector } from '@/lib/hooks';
import { CartItem, Item, User, UserType } from '@/types';
import { fetchItems } from '@/modules/items';
import { getTicketPrice } from '@/modules/users';
import { fetchCurrentTeam } from '@/modules/team';
import { useSearchParams } from 'next/navigation';

const Payment = () => {
const router = useRouter();
const query: SearchParams = useSearchParams();
const stripe = useStripe();
const elements = useElements();

useEffect(() => {
if (query.type === 'success') {
toast.success('Paiement effectué avec succès');
} else if (query.type === 'error') {
switch (query.error) {
case 'CART_NOT_FOUND':
toast.error('Panier introuvable');
break;
case 'TRANSACTION_ERROR':
toast.error('La transaction a échoué');
break;
case 'NO_PAYLOAD':
toast.error('Requête erronée');
break;
default:
break;
const dispatch = useAppDispatch();

const user = useAppSelector((state) => state.login.user) as User;

const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const cartStr = useSearchParams().get('cart');
const cart = useMemo(() => (cartStr ? JSON.parse(cartStr) : null), [cartStr]);

const [items, setItems] = useState<Item[] | null>(null);
// The team the player is in
const team = useAppSelector((state) => state.team.team);
// The members of the team are the players and the coaches
const [teamMembers, setTeamMembers] = useState<User[] | null>(null);

// Contains the ticket items for each ticket in the cart. This is an object, keys are userIds and values are the items
const [tickets, setTickets] = useState<
| {
[userId: string]: CartItem;
}
| undefined
>(undefined);

// Fetch items, team and checks if user already have an attendant
useEffect(() => {
if (user.teamId) {
dispatch(fetchCurrentTeam());
} else if (user.type === UserType.spectator) {
// Organizers should not be able to buy tickets if they are not in a team
setTeamMembers([
{
id: user.id,
hasPaid: user.hasPaid,
username: user.username,
age: user.age,
attendant: user.attendant,
type: user.type,
} as User,
]);
} else {
setTeamMembers([]);
}
router.push('/dashboard');

(async () => {
setItems(await fetchItems());
})();
}, []);

return false;
// Initializing teamMembers
useEffect(() => {
if (!team) {
return;
}
setTeamMembers(team.players.concat(team.coaches));
}, [team]);

// Checks if the place of the user is already in the cart
// Checks if the user already have an attendant
// Initializes teamMembersWithoutTicket
// Fills tickets
useEffect(() => {
if (!cart || !teamMembers || !items) return;

(async () => {
// First, we make all the requests
const ticketsArray = (
await Promise.allSettled(
cart.tickets.userIds.map((userId: string) =>
userId === user.id ? items.find((item) => item.id === `ticket-${user.type}`) : getTicketPrice(userId),
),
)
)
.filter((val): val is PromiseFulfilledResult<Item> => val.status === 'fulfilled')
// Then, we only keep the return value of the Promises
.map((result) => result.value)
// And finally, we remove failed Promises
.filter((ticket, i) => {
if (!ticket) {
// toast.error(
// `Une erreur est survenue en cherchant le prix du ticket de l'utilisateur avec l'identifiant ${cart.tickets.userIds[i]}. Si ce problème persiste, contacte le support`,
// );
const newCart = { ...cart };
newCart.tickets.userIds.splice(i, 1);
return false;
}
return true;
});
setTickets(ticketsArray.reduce((prev, curr, i) => ({ ...prev, [cart.tickets.userIds[i]]: curr }), {}));
})();
}, [items, cart, teamMembers]);

if (!items || !teamMembers || !tickets || !cart) {
return (
<div className={styles.loader}>
<Loader />
</div>
);
}

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!stripe || !elements) {
return;
}

setIsLoading(true);

const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: process.env.NEXT_PUBLIC_URL + '/dashboard/payment/callback',
},
});

// This point will only be reached if there is an immediate error when
// confirming the payment. Otherwise, the customer will be redirected to
// `/payment/callback`.
if (error.type === 'card_error' || error.type === 'validation_error') {
setMessage(error.message!.toString());
} else {
setMessage("Une erreur inconnue s'est produite");
}

setIsLoading(false);
};

const paymentElementOptions = {
layout: 'tabs',
} as StripePaymentElementOptions;

return (
<div className={styles.paymentContainer}>
<div className={styles.stripePaymentContainer}>
<form id="payment-form" onSubmit={handleSubmit} className={styles.stripeForm}>
<Title level={2} align="center">
Paiement
</Title>
<PaymentElement id="payment-element" options={paymentElementOptions} />
<Button primary type="submit" disabled={isLoading || !stripe || !elements}>
<span id="button-text">{isLoading ? <div className="spinner" id="spinner"></div> : 'Payer'}</span>
</Button>
{/* Show any error or success messages */}
{message && <div id="payment-message">{message}</div>}
</form>
<div className={styles.bill}>
<Cart
cart={cart}
tickets={tickets!}
items={items!}
teamMembers={teamMembers!}
onItemRemoved={null}
onTicketRemoved={null}
onCartReset={null}
/>
</div>
</div>
<p>
Paiement géré par <a href="https://stripe.com/fr">Stripe Payments Europe, Ltd.</a>
</p>
</div>
);
};

export default Payment;
Loading

0 comments on commit 7b32183

Please sign in to comment.