;
14 |
15 | export const ShortText: Story = {
16 | args: {
17 | children: 'Short text',
18 | },
19 | };
20 |
21 | export const LongText: Story = {
22 | args: {
23 | children: (
24 |
25 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec in tortor porta, fermentum mi convallis,
26 | vulputate arcu. Cras sit amet vestibulum ex. Phasellus egestas scelerisque nisi, vel ultrices lacus imperdiet
27 | sit amet. Phasellus vitae est at ligula maximus sodales eu pharetra justo. Pellentesque dapibus varius
28 | tincidunt. Nam viverra, ex non vestibulum dictum, sapien enim lobortis magna, sed consequat lorem metus et nisi.
29 | Donec sit amet nisi dignissim, varius felis consequat, semper turpis. Proin posuere pretium molestie. Vestibulum
30 | non rutrum elit. Nulla sed semper augue. Maecenas porta accumsan consectetur. Nunc molestie aliquam volutpat.
31 | Praesent semper augue gravida augue pellentesque, consectetur eleifend odio volutpat. Nulla sapien elit,
32 | pellentesque vel magna vel, pellentesque tincidunt dui. Vestibulum lacinia mi augue. Quisque imperdiet nulla
33 | sem. Donec sodales dui ut venenatis egestas. Nullam vitae nibh vestibulum, tristique nisi vitae, elementum mi.
34 | Proin fermentum et elit convallis accumsan. Quisque sodales pulvinar massa ut venenatis. Class aptent taciti
35 | sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Quisque eu egestas tortor. Aliquam id
36 | ante vitae massa pulvinar dapibus. Quisque purus dui, suscipit et erat quis, blandit ultrices neque. Vivamus ac
37 | commodo diam. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque gravida a eros id lacinia. Fusce
38 | eget justo at elit vestibulum commodo. Pellentesque nisl lorem, euismod id egestas eu, aliquet vel libero. Cras
39 | at metus id nisl rutrum vestibulum. Fusce quis suscipit massa. Pellentesque et rutrum massa, eget bibendum
40 | libero. Nunc pellentesque est placerat nisl laoreet pretium. Praesent tempus eleifend consectetur. In commodo
41 | suscipit sem, vel imperdiet turpis accumsan non. Ut posuere diam augue, non accumsan turpis imperdiet non.
42 | Quisque eget sem ut nunc vestibulum iaculis. Nulla pretium nunc nisl, at efficitur risus molestie vitae. Duis
43 | felis erat, consequat at tellus suscipit, mollis posuere nulla. Nulla a tristique ipsum, quis finibus erat.
44 | Nullam eu augue velit. Sed sollicitudin feugiat dui. Nunc sed varius sapien. Phasellus feugiat dapibus rhoncus.
45 | Mauris lobortis justo ac elementum euismod. Integer quis risus a velit aliquet efficitur. Phasellus aliquet
46 | metus non orci rutrum, semper finibus odio sollicitudin. Sed hendrerit velit elit, nec ornare dolor imperdiet
47 | sed. Integer porttitor tempus sapien, et consequat eros consectetur vel. Etiam rhoncus erat sed felis accumsan
48 | vehicula. Quisque urna eros, accumsan vitae nisi vitae, viverra tincidunt tellus. Aliquam erat volutpat. Aenean
49 | vulputate enim felis, ac pellentesque diam elementum at. Vestibulum ut sapien cursus, suscipit mi consectetur,
50 | fermentum tortor. Nam nulla sem, viverra vel varius nec, mattis sit amet eros. Nunc semper ante id fringilla
51 | tempor. Mauris vehicula nisi risus, sit amet gravida dui mollis id. Mauris sit amet lacus eu erat cursus
52 | elementum quis posuere turpis. Donec lacus urna, scelerisque non erat at, eleifend laoreet dui. Nam dignissim
53 | nibh id sodales mollis. Morbi euismod orci in metus vestibulum scelerisque. Nullam orci purus, vulputate vitae
54 | viverra non, varius id felis. Duis porta nunc lacus, vitae pretium est accumsan sed. Praesent in imperdiet
55 | tortor, vel pellentesque erat. Nunc luctus lacus sed massa cursus iaculis. Quisque neque enim, fringilla vel
56 | congue id, aliquet vitae odio. Maecenas vestibulum at justo id ornare. Mauris tempor pretium urna, et vehicula
57 | quam tincidunt in. Donec aliquam magna ac libero congue, non ultrices urna condimentum. Donec nunc leo, bibendum
58 | ut consequat nec, gravida euismod nunc. Curabitur tellus lectus, pellentesque ac tellus non, consequat finibus
59 | libero. Donec ut libero at justo posuere scelerisque. Donec venenatis metus libero, at tincidunt leo consequat
60 | id. Nulla facilisi.
61 |
62 | ),
63 | },
64 | };
65 |
--------------------------------------------------------------------------------
/app/routes/events_.$eventId.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction, ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
2 | import { useParams, useLoaderData, Link, Form } from '@remix-run/react';
3 | import { db } from '~/modules/database/db.server';
4 | import { json, redirect } from '@remix-run/node';
5 | import { eventDataPatcher } from '~/modules/events/event';
6 | import { requireUserSession } from '~/modules/session/session.server';
7 |
8 | export const meta: MetaFunction = ({ data }) => {
9 | const eventFullName = data.event.name;
10 | const regex = /^[^:]+/;
11 | const eventName = eventFullName.match(regex);
12 | const eventDescription = data.event.description;
13 |
14 | return [{ title: `${eventName} | Social Plan-It` }, { name: 'description', content: `${eventDescription}` }];
15 | };
16 |
17 | export async function loader({ params }: LoaderFunctionArgs): Promise {
18 | const event = await db.event.findFirst({
19 | where: { id: params.eventId },
20 | include: {
21 | group: true,
22 | users: {
23 | select: {
24 | id: true,
25 | name: true,
26 | },
27 | },
28 | },
29 | });
30 | if (!event) {
31 | throw new Response(null, {
32 | status: 404,
33 | statusText: 'Event ID Not Found',
34 | });
35 | }
36 | return json({ event });
37 | }
38 |
39 | export async function action({ params, request }: ActionFunctionArgs) {
40 | const userSession = await requireUserSession(request);
41 | const formData = await request.formData();
42 | const intent = formData.get('intent');
43 |
44 | switch (intent) {
45 | case 'joinEvent': {
46 | await db.event.update({
47 | where: { id: params.eventId },
48 | data: { users: { connect: { id: userSession.userId } } },
49 | });
50 | return redirect(`/events/${params.eventId}`);
51 | }
52 | case 'deleteEvent': {
53 | await db.event.delete({
54 | where: { id: params.eventId },
55 | });
56 | return redirect(`/events`);
57 | }
58 | default: {
59 | throw new Response(`Invalid event intent: ${intent}`, { status: 400 });
60 | }
61 | }
62 | }
63 |
64 | export default function EventRoute() {
65 | const rawData = useLoaderData();
66 | const event = eventDataPatcher(rawData.event);
67 |
68 | const date = event.date.toLocaleString('en-US', {
69 | weekday: 'short',
70 | month: 'short',
71 | day: 'numeric',
72 | hour: 'numeric',
73 | minute: 'numeric',
74 | hour12: true,
75 | timeZone: 'America/Los_Angeles',
76 | timeZoneName: 'short',
77 | });
78 | const parts = date.split(', ');
79 | const formattedTime = `${parts[0]}, ${parts[1]} - ${parts[2]}`;
80 |
81 | return (
82 | <>
83 | Back to events
84 |
85 | {event.imgUrl && event.imgAlt && (
86 |
92 | )}
93 |
{event.name}
94 |
{formattedTime}
95 |
{event.description}
96 |
97 |
Hosted at: {event.location}
98 |
99 | Hosted by:{' '}
100 |
101 | {event?.group?.name}
102 |
103 |
104 |
105 |
Current participants ({event.users?.length}):
106 |
107 | {event.users?.map((user) => {
108 | return {user.name} ;
109 | })}
110 |
111 |
129 |
130 | >
131 | );
132 | }
133 |
134 | export function ErrorBoundary() {
135 | const { eventId } = useParams();
136 | return There was an error loading event by the id {eventId}.
;
137 | }
138 |
--------------------------------------------------------------------------------
/app/routes/about-us.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from '@remix-run/node';
2 | import { Image, staticImage } from '~/components/ui/images';
3 |
4 | export default function AboutUs() {
5 | return (
6 |
7 |
8 |
9 |
17 |
18 |
Empower Community Building
19 |
20 | Our open-source platform empowers you to create and manage your own communities, fostering a space for
21 | individuals with shared interests to connect, collaborate, and thrive.
22 |
23 |
27 | Join Group
28 |
29 |
30 | {/*
31 |
TODO: Place holder for event images marquee
32 | */}
33 |
34 |
35 |
43 |
44 |
45 |
Experience Unparalleled Flexibility
46 |
47 |
48 | Enjoy the freedom to shape your social experience on your terms. Our platform offers customizable features,
49 | allowing you to tailor event formats, communication channels, and community guidelines to perfectly suit your
50 | unique needs!
51 |
52 |
53 |
54 |
55 |
63 |
64 |
65 |
Uncover Diverse Passions
66 |
67 |
68 | Explore a vibrant tapestry of communities dedicated to anything you can imagine! From book clubs and art
69 | enthusiasts to tech aficionados and board game nights, discover new passions and expand your horizons through
70 | community hosted events.
71 |
72 |
73 |
74 |
75 |
83 |
84 |
85 |
Foster Meaningful Connections
86 |
87 |
88 | Facilitates genuine connections with like-minded individuals who share your interests. Find your tribe, spark
89 | engaging discussions, and build lasting friendships through shared experiences.
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | export const meta: MetaFunction = () => {
97 | return [
98 | { title: 'About | Social Plan-It' },
99 | { name: 'description', content: 'Checkout what Social Plan-It is all about' },
100 | ];
101 | };
102 |
--------------------------------------------------------------------------------
/app/modules/session/webauthn.server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | generateAuthenticationOptions,
3 | generateRegistrationOptions,
4 | verifyAuthenticationResponse,
5 | verifyRegistrationResponse,
6 | } from '@simplewebauthn/server';
7 |
8 | import { db } from '~/modules/database/db.server';
9 |
10 | import type { Authenticator, User } from '@prisma/client';
11 | import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';
12 | import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/types';
13 |
14 | interface UserWithAuthenticators extends User {
15 | authenticators: Authenticator[];
16 | }
17 |
18 | const rpName = 'Social Plan it';
19 |
20 | const rpID = process.env.WEBAUTHN_RELYING_PARTY_ID ?? '';
21 | if (!rpID) {
22 | throw new Error('WEBAUTHN_RELYING_PARTY_ID must be set');
23 | }
24 |
25 | const origin = process.env.DOMAIN ?? '';
26 | if (!origin) {
27 | throw new Error('DOMAIN must be set');
28 | }
29 |
30 | export async function getPasskeyRegistrationOptions(user: UserWithAuthenticators) {
31 | const options = await generateRegistrationOptions({
32 | rpName,
33 | rpID,
34 | userID: user.id,
35 | userName: user.email,
36 | attestationType: 'none',
37 | excludeCredentials: user.authenticators.map((authenticator) => ({
38 | type: 'public-key',
39 | id: new Uint8Array(Buffer.from(authenticator.credentialID, 'base64')),
40 | })),
41 | authenticatorSelection: {
42 | residentKey: 'preferred',
43 | userVerification: 'preferred',
44 | },
45 | });
46 |
47 | await db.user.update({ where: { id: user.id }, data: { currentChallenge: options.challenge } });
48 |
49 | return options;
50 | }
51 |
52 | export async function verifyPasskeyRegistrationResponse(
53 | user: UserWithAuthenticators,
54 | response: RegistrationResponseJSON,
55 | ): Promise {
56 | if (!user.currentChallenge) return { verified: false };
57 |
58 | try {
59 | const verification = await verifyRegistrationResponse({
60 | response,
61 | expectedChallenge: user.currentChallenge,
62 | expectedOrigin: origin,
63 | expectedRPID: rpID,
64 | });
65 |
66 | if (verification.verified) {
67 | const { registrationInfo } = verification;
68 | if (!registrationInfo) return { verified: false };
69 |
70 | const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } = registrationInfo;
71 |
72 | await db.authenticator.create({
73 | data: {
74 | userId: user.id,
75 | credentialID: Buffer.from(credentialID)
76 | .toString('base64')
77 | .replace(/\+/g, '-')
78 | .replace(/\//g, '_')
79 | .replace(/=+$/, ''),
80 | credentialPublicKey: Buffer.from(credentialPublicKey),
81 | counter,
82 | credentialDeviceType,
83 | credentialBackedUp,
84 | },
85 | });
86 | await db.user.update({ where: { id: user.id }, data: { currentChallenge: null } });
87 | }
88 |
89 | return verification;
90 | } catch {
91 | return { verified: false };
92 | }
93 | }
94 |
95 | export async function getPasskeyAuthenticationOptions(user: UserWithAuthenticators) {
96 | const options = await generateAuthenticationOptions({
97 | rpID,
98 | allowCredentials: user.authenticators.map((authenticator) => ({
99 | type: 'public-key',
100 | id: new Uint8Array(Buffer.from(authenticator.credentialID, 'base64')),
101 | })),
102 | userVerification: 'preferred',
103 | });
104 |
105 | await db.user.update({ where: { id: user.id }, data: { currentChallenge: options.challenge } });
106 |
107 | return options;
108 | }
109 |
110 | export async function verifyPasskeyAuthenticationResponse(
111 | user: UserWithAuthenticators,
112 | response: AuthenticationResponseJSON,
113 | ): Promise {
114 | const authenticator = user.authenticators.find(({ credentialID }) => {
115 | return credentialID === response.id;
116 | });
117 | if (!authenticator || !user.currentChallenge) return { verified: false };
118 |
119 | try {
120 | const verification = await verifyAuthenticationResponse({
121 | response,
122 | expectedChallenge: user.currentChallenge,
123 | expectedOrigin: origin,
124 | expectedRPID: rpID,
125 | authenticator: {
126 | ...authenticator,
127 | credentialID: new Uint8Array(Buffer.from(authenticator.credentialID, 'base64')),
128 | },
129 | });
130 |
131 | if (verification.verified) {
132 | const { authenticationInfo } = verification;
133 | if (!authenticationInfo) return { verified: false };
134 |
135 | const { newCounter } = authenticationInfo;
136 |
137 | await db.authenticator.update({ where: { id: authenticator.id }, data: { counter: newCounter } });
138 | await db.user.update({ where: { id: user.id }, data: { currentChallenge: null } });
139 | }
140 |
141 | return verification;
142 | } catch {
143 | return { verified: false };
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/app/imgs/VercelBanner.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import VercelBanner from '~/imgs/VercelBanner.svg';
2 |
3 | export default function Footer() {
4 | return (
5 |
6 |
7 | {/*Top Line*/}
8 |
17 | {/*Top Line Mobile View */}
18 |
23 | {/*Main Links*/}
24 |
25 |
48 |
76 |
104 |
117 |
118 |
119 | {/*Bottom Links*/}
120 |
137 |
138 |
139 | );
140 | }
141 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Plan-it Social
2 |
3 | Welcome to `plan-it-social-web`! These are some quick notes on how to start contributing to our open source project. A good place to start is to join our [Discord server](https://discord.gg/tTD7PvwpuX).
4 |
5 | The group meets most Mondays 6pm PST ([meetup](https://www.meetup.com/all-things-web-react-html-css-javascript-tutoring/))
6 | Small Co-working sessions are held on Sunday 9am PST on [Discord](https://discord.gg/tTD7PvwpuX)
7 |
8 | ## Community
9 |
10 | - [Discord server](https://discord.gg/tTD7PvwpuX)
11 | - [Meetup](https://www.meetup.com/all-things-web-react-html-css-javascript-tutoring/)
12 |
13 | ## What we're using
14 |
15 | - [GitHub](https://github.com/social-plan-it)
16 | - [Figma](https://www.figma.com/)
17 | - [PostgreSQL](https://www.postgresql.org/)
18 | - [Supabase](https://supabase.com/)
19 | - [Prisma](https://www.prisma.io/)
20 | - [React 18](https://react.dev)
21 | - [Remix](https://remix.run/docs)
22 | - [Tailwind CSS](https://tailwindcss.com/)
23 | - [TypeScript](https://www.typescriptlang.org/)
24 | - [Vercel](https://vercel.com/)
25 |
26 | ## Design Development
27 |
28 | - [Figma wire-frame](https://www.figma.com/file/6e3cBuEHOIpWvqT31Zd29p/Social-Plan-it?type=design&node-id=0-1&mode=design&t=DpLSfRITCDDG1pj0-0)
29 |
30 | ## Development
31 |
32 | To run your Remix app locally, make sure your project's local dependencies are installed and environment variables are set up:
33 |
34 | ```sh
35 | npm install
36 | ```
37 |
38 | ```sh
39 | mv .env.example .env
40 | ```
41 |
42 | Then, you'll need to fill in the `.env` file with the appropriate values.
43 | Come to the Discord server or meetup for help with this step.
44 |
45 | Afterwards, start the Remix development server like so:
46 |
47 | ```sh
48 | npm run dev
49 | ```
50 |
51 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready to go!
52 |
53 | If you're used to using the `vercel dev` command provided by [Vercel CLI](https://vercel.com/cli) instead, you can also use that, but it's not needed.
54 |
55 |
56 |
57 | ## Storybook ( UI Component Development )
58 |
59 | ```sh
60 | npm run storybook
61 | ```
62 |
63 | then visit [http://localhost:6006](http://localhost:6006) to see all the components in action.
64 |
65 | ## Contributing
66 |
67 | 1. If you're a contributor to the repo skip to `Step 2`
68 | 1. Join the group, check out the [Discord server](https://discord.gg/tTD7PvwpuX)!
69 | 2. Fork the repo
70 | 3. Clone your fork
71 | 4. Set your upstream to the project main branch to avoid merge conflicts `git remote add upstream https://github.com/social-plan-it/plan-it-social-web.git`
72 | 2. Create a branch `git checkout -b `
73 | 3. Add your `.env` file ([example here](./EXAMPLE.ENV) or ask the Discord for help on details)
74 | 4. Run `npm install`
75 | 5. Make your changes
76 | 6. Add you changed files with `git add` and `git commit -m ""`
77 | 7. Push your changes to your fork with `git push`
78 | 8. Create a pull request
79 | 9. Iterate on the solution
80 | 10. Get merged!
81 |
82 | ## VS Code Setup
83 |
84 | Use the following settings to format your files on save:
85 |
86 | ```json
87 | {
88 | // These are all my auto-save configs
89 | "editor.formatOnSave": true,
90 | // turn it off for JS and JSX, we will do this via eslint
91 | "[javascript][javascriptreact][typescript][typescriptreact]": {
92 | "editor.formatOnSave": false
93 | },
94 | // tell the ESLint plugin to run on save
95 | "editor.codeActionsOnSave": {
96 | "source.fixAll.eslint": true
97 | }
98 | }
99 | ```
100 |
101 | ## Prisma
102 |
103 | We've created some handy scripts to help with database management with Prisma. Occasionally you might need to use these to update the database on your local machine.
104 |
105 | - `npm run build:db` - [generate your prisma client](https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/generating-prisma-client) when starting out or when updates are made to the Prisma schema
106 |
107 | - `npm run update:db` - [prototype your schema](https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push) to iterate on schema design locally
108 |
109 | - `npm run seed:db` - [consistently create data by seeding](https://www.prisma.io/docs/guides/migrate/seed-database) data into our database. We have a slightly different setup than when is in the Prisma docs. We are using `--require tsconfig-paths/register` to use the `~` path feature in Remix ([ref: Kent C. Dodds](https://github.com/remix-run/blues-stack/issues/143#issuecomment-1515339235))
110 |
111 | ## Running Tests
112 |
113 | ### Code Style
114 |
115 | We use ESLint to enforce code style. You can run the linter using the following command:
116 |
117 | ```sh
118 | npm run lint
119 | ```
120 |
121 | for auto fix
122 |
123 | ```sh
124 | npm run lint:fix
125 | ```
126 |
127 | Also included in our documentation, are great instructions on how to [setup this functionality to automatically run on save in VS code](./docs/formatting-and-linting.md).
128 |
129 | ### Type Checking
130 |
131 | We use TypeScript to enforce static typing. You can run the type checker using the following command:
132 |
133 | ```sh
134 | npm run typecheck
135 | ```
136 |
137 | ### End-to-End Test
138 |
139 | You can run the test suite using the following commands:
140 |
141 | ```sh
142 | npm run test:e2e
143 | ```
144 |
145 | Please ensure that the tests are passing when submitting a pull request.
146 | Or get help from the Discord community to get them passing.
147 |
--------------------------------------------------------------------------------
/app/components/ui/images.tsx:
--------------------------------------------------------------------------------
1 | import { useState, type ImgHTMLAttributes } from 'react';
2 | import type { ImageProps as UnpicImageProps } from '@unpic/react';
3 | import { Image as UnpicImage } from '@unpic/react';
4 |
5 | type ImageProps = ImgHTMLAttributes &
6 | UnpicImageProps & {
7 | alt: string;
8 | src: string;
9 | width: number;
10 | height: number;
11 | };
12 |
13 | export function Image({ ...props }: ImageProps) {
14 | const [loaded, setLoaded] = useState(false);
15 |
16 | return (
17 |
18 | {!loaded &&
}
19 |
20 | {
23 | setLoaded(true);
24 | }}
25 | />
26 |
27 |
28 | );
29 | }
30 |
31 | interface ImageDetails {
32 | url: string;
33 | altText: string;
34 | title?: string;
35 | }
36 |
37 | const staticImage: { [key: string]: ImageDetails } = {
38 | avatarAstronaut: {
39 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716394728/SocialPlanIt/avatar-astronaut_100x100',
40 | altText: 'Astronaut',
41 | title: 'Astronaut',
42 | },
43 | companyLogoHorizontal: {
44 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391878/SocialPlanIt/SocialPlan-it-logo-Horizontal_760x121',
45 | altText: 'Social Planet logo horizontal',
46 | title: 'Horizontal Company Logo',
47 | },
48 | companyLogoStacked: {
49 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391876/SocialPlanIt/SocialPlan-it-logo-stacked',
50 | altText: 'Social Planet logo stacked',
51 | title: 'Stacked Company Logo',
52 | },
53 | kidWithBinoculars: {
54 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391874/SocialPlanIt/Kid_looking_through_binoculars-512x512',
55 | altText: 'Kid looking through binoculars',
56 | title: 'Kid with Binoculatrs',
57 | },
58 | defaultEventPhoto: {
59 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391884/SocialPlanIt/default-event-photo-302x215',
60 | altText: 'Animated characters in a remote meeting',
61 | title: 'Animated remote meeting',
62 | },
63 | defaultGroupPhoto: {
64 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391867/SocialPlanIt/default-group-photo-512x512',
65 | altText: 'Two friends taking a selfie',
66 | title: 'Two Friends in a Selfie',
67 | },
68 | discord: {
69 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391871/SocialPlanIt/purple_discord_logo-100x100',
70 | altText: 'Discord logo in purple circle',
71 | title: 'Discord',
72 | },
73 | diverseGroupOfThree: {
74 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391871/SocialPlanIt/Diverse_group_of_three-512x512',
75 | altText: 'Diverse group of three characters',
76 | title: 'Diverse Group of Three',
77 | },
78 | gpsLocation: {
79 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391867/SocialPlanIt/GPS_Location_100x100',
80 | altText: 'GPS location emoji',
81 | title: 'GPS',
82 | },
83 | happySun: {
84 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391871/SocialPlanIt/Happy_sun_with_four_planets-512x512',
85 | altText: 'Happy sun surrounded by four planets',
86 | title: 'Happy Sun',
87 | },
88 | loadingPhoto: {
89 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391883/SocialPlanIt/SocialPlanit-Loading-220x220',
90 | altText: 'Circular silhouette of a spaceship headed to Saturn',
91 | title: 'Loading Photo',
92 | },
93 | favicon: {
94 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391880/SocialPlanIt/SocialPlan-it-logo-Favicon',
95 | altText: 'Red Saturn',
96 | title: 'Company Favicon',
97 | },
98 | saturnSilhouette: {
99 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716359837/SocialPlanIt/Planet_Silhouette_White',
100 | altText: 'Silhouette of a Saturn shape',
101 | title: 'Saturn Silhouette',
102 | },
103 | share: {
104 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391867/SocialPlanIt/Arrow_Share-100x100',
105 | altText: 'Triangular arrow pointing North West',
106 | title: 'Share Arrow',
107 | },
108 | targetWithArrow: {
109 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391875/SocialPlanIt/Target_with_arrow-512x512',
110 | altText: 'Red and white target with arrow in the center',
111 | title: 'Target with Arrow',
112 | },
113 | twitter: {
114 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716391867/SocialPlanIt/blue_twitter_logo-100x100',
115 | altText: 'Twitter logo in blue circle',
116 | title: 'Twitter',
117 | },
118 | largeGroup: {
119 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716412303/SocialPlanIt/Large-Group-Hero-Image_553x379',
120 | altText: 'Large group of friends',
121 | title: 'Large Group',
122 | },
123 | womenCollaborating: {
124 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716412303/SocialPlanIt/Women-collaborating_500x500',
125 | altText: 'Four women collaborating on a laptop',
126 | title: 'Women Collaborating',
127 | },
128 | friendsOnABench: {
129 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716412303/SocialPlanIt/Group-of-friends-on-a-bench_500x500',
130 | altText: '5 friends on a bench',
131 | title: 'Friends on a bench',
132 | },
133 | womenOnStaircase: {
134 | url: 'https://res.cloudinary.com/dxctpvd8v/image/upload/v1716412303/SocialPlanIt/Women-on-a-staircase_500x500',
135 | altText: 'Women on a staircase',
136 | title: 'Women on a Staircase',
137 | },
138 | };
139 | export { staticImage };
140 |
--------------------------------------------------------------------------------
/app/routes/groups.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Link, redirect, useLoaderData, useSearchParams } from '@remix-run/react';
2 | import { Image, staticImage } from '~/components/ui/images';
3 | import { LinkButton } from '~/components/ui/links';
4 | import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
5 | import type { Group } from '@prisma/client';
6 | import { findGroups } from '~/modules/database/groups.server';
7 |
8 | const pageCount = 24;
9 |
10 | function getQueryString(pageNum: number | null, query: string | null) {
11 | const searchParams = new URLSearchParams();
12 | if (pageNum) {
13 | searchParams.append('p', '' + pageNum);
14 | }
15 | if (query) {
16 | searchParams.append('q', query);
17 | }
18 | return searchParams.toString();
19 | }
20 |
21 | export async function loader({ request }: LoaderFunctionArgs) {
22 | const url = new URL(request.url);
23 | const query = url.searchParams.get('q');
24 | const page = url.searchParams.get('p');
25 | const pageNum = page ? Number.parseInt(page, 10) : 1;
26 | //add Zod validation
27 |
28 | const [groups, count] = await findGroups({
29 | query: query || undefined,
30 | count: pageCount,
31 | skip: pageCount * (pageNum - 1),
32 | });
33 |
34 | const maxPage = Math.ceil(count / pageCount);
35 |
36 | if (pageNum > maxPage) {
37 | return redirect(`/groups/?${getQueryString(maxPage, query)}`);
38 | }
39 | if (pageNum < 1) {
40 | return redirect(`/groups/?${getQueryString(maxPage, query)}`);
41 | }
42 |
43 | return { groups, count };
44 | }
45 |
46 | export default function Group() {
47 | const { groups, count } = useLoaderData();
48 | const [searchParams] = useSearchParams();
49 | const page = searchParams.get('p');
50 | const query = searchParams.get('q');
51 | const pageNum = page ? Number.parseInt(page, 10) : 1;
52 |
53 | return (
54 |
55 |
56 |
90 |
91 | {groups?.map((group: Group) => {
92 | return (
93 |
94 |
95 |
96 |
97 |
105 |
106 |
107 |
{group.name}
108 | Group-headline-here
109 |
110 |
111 |
112 |
113 | );
114 | })}
115 |
116 |
117 |
118 |
119 | Previous
120 |
121 | Next
122 |
123 |
124 |
125 |
126 | Create New Group
127 |
128 |
129 | );
130 | }
131 |
132 | export const meta: MetaFunction = () => {
133 | return [
134 | { title: 'Groups | Social Plan-It' },
135 | { name: 'description', content: "We have an array of groups you'd love. Come join a group!" },
136 | ];
137 | };
138 |
--------------------------------------------------------------------------------
/app/components/layout/top-nav.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Form, Link, useNavigation } from '@remix-run/react';
3 | import { useCurrentUser } from '~/hooks/useCurrentUser';
4 | import { StyledMenu } from '~/components/ui/menu';
5 | import { Menu } from '@headlessui/react';
6 | import { staticImage, Image } from '../ui/images';
7 |
8 | export function TopNav() {
9 | const currentUser = useCurrentUser();
10 | const [isMenuOpen, setIsMenuOpen] = useState(false);
11 |
12 | const toggleMenu = () => {
13 | setIsMenuOpen(!isMenuOpen);
14 | };
15 |
16 | return (
17 |
18 |
19 | Skip to content
20 |
21 |
22 |
23 |
24 |
31 |
32 |
33 |
34 |
35 | Home
36 |
37 |
38 | About
39 |
40 |
41 | Events
42 |
43 |
44 | Groups
45 |
46 |
47 | {currentUser ? (
48 |
49 | ) : (
50 |
51 |
52 | Log in
53 |
54 |
55 |
56 | Sign up
57 |
58 |
59 |
60 | )}
61 |
62 |
63 |
64 |
71 | {isMenuOpen ? (
72 |
73 | ) : (
74 |
75 | )}
76 |
77 |
78 |
79 | {isMenuOpen && (
80 |
81 |
82 | Home
83 |
84 |
85 | About
86 |
87 |
88 | Events
89 |
90 |
91 | Groups
92 |
93 | {currentUser ? (
94 | <>
95 |
96 | Settings
97 |
98 |
99 | >
100 | ) : (
101 | <>
102 |
103 | Log in
104 |
105 |
106 | Sign up
107 |
108 | >
109 | )}
110 |
111 | )}
112 |
113 | );
114 | }
115 |
116 | export function LogoutButton({ className }: { className?: string }) {
117 | const navigation = useNavigation();
118 | const formAction = '/logout';
119 | const isPending =
120 | navigation.formAction === formAction && (navigation.state === 'submitting' || navigation.state === 'loading');
121 |
122 | return (
123 |
128 | );
129 | }
130 |
131 | export function TopNavUserMenu() {
132 | const currentUser = useCurrentUser();
133 | return (
134 |
135 |
138 | {currentUser?.name[0].toUpperCase()}
139 |
140 | }
141 | >
142 | <>
143 |
144 |
145 | Settings
146 |
147 |
148 |
149 |
150 |
151 | >
152 |
153 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/app/routes/groups_.new.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
2 | import { redirect } from '@remix-run/node';
3 | import { Form, useActionData, useLoaderData } from '@remix-run/react';
4 | import { createClient } from '@supabase/supabase-js';
5 | import { z } from 'zod';
6 |
7 | import { db } from '~/modules/database/db.server';
8 |
9 | import { Card } from '~/components/ui/containers';
10 | import { ACCEPTED_IMAGE_TYPES, ImageUpload, Input, MAX_FILE_SIZE_MB, TextArea } from '~/components/ui/forms';
11 | import { Button } from '~/components/ui/button';
12 | import { H1, H2 } from '~/components/ui/headers';
13 | import { requireUserSession } from '~/modules/session/session.server';
14 | import { badRequest } from '~/modules/response/response.server';
15 | import { invokeBackgroundTask } from '~/modules/background-tasks/invoke';
16 | import { requireValidCsrfToken } from '~/modules/csrf/csrf.server';
17 |
18 | export async function loader({ request }: LoaderFunctionArgs) {
19 | const userSession = await requireUserSession(request);
20 | const csrfToken = userSession.csrfToken;
21 | return { csrfToken };
22 | }
23 |
24 | export async function action({ request }: ActionFunctionArgs) {
25 | const userSession = await requireUserSession(request);
26 | const form = await request.formData();
27 | const csrfToken = form.get('csrfToken');
28 | await requireValidCsrfToken(userSession.csrfToken, csrfToken);
29 |
30 | const formObject = await z
31 | .object({
32 | groupName: z.string().min(1, 'Group name is required.'),
33 | description: z.string().min(1, 'Description is required.'),
34 | groupImage: z
35 | .instanceof(File)
36 | .refine(
37 | // if no file is attached, it's an empty File object with no name
38 | (file) => !file.name || file?.size <= MAX_FILE_SIZE_MB * 1024 * 1024,
39 | `File size can't exceed ${MAX_FILE_SIZE_MB}MB.`,
40 | )
41 | .refine(
42 | // if no file is attached, it's an empty File object with no name
43 | (file) => !file.name || ACCEPTED_IMAGE_TYPES.split(', ').includes(file.type),
44 | `Only ${ACCEPTED_IMAGE_TYPES} are supported.`,
45 | ),
46 | })
47 | .safeParseAsync(Object.fromEntries(form));
48 |
49 | if (!formObject.success) {
50 | return badRequest({
51 | success: false,
52 | error: { message: `the following fields contains errors: ${formObject.error}` },
53 | });
54 | }
55 |
56 | const { groupName: name, description, groupImage } = formObject.data;
57 |
58 | let groupImageUrl = '';
59 | if (groupImage && groupImage instanceof File && groupImage.name) {
60 | // Create a single supabase client for interacting with your database
61 | if (!process.env.SUPABASE_SECRET) throw new Error('SUPABASE_SECRET is not defined');
62 | const supabase = createClient('https://fzzehiiwadkmbvpouotf.supabase.co', process.env.SUPABASE_SECRET);
63 | const uuid = crypto.randomUUID();
64 | try {
65 | const { data, error } = await supabase.storage
66 | .from('group-cover-images')
67 | .upload(`${uuid}-${groupImage.name}`, groupImage, {
68 | cacheControl: '3600',
69 | upsert: false,
70 | });
71 | groupImageUrl = `https://fzzehiiwadkmbvpouotf.supabase.co/storage/v1/object/public/group-cover-images/${data?.path}`;
72 | if (error) {
73 | return { error: { message: `Error uploading image: ${error.message}` } };
74 | }
75 | } catch (error) {
76 | return { error: { message: `Unexpected Error during Image Upload: ${error}` } };
77 | }
78 | }
79 |
80 | const new_group = await db.group.create({
81 | data: { name, description, imgUrl: groupImageUrl, imgAlt: 'The group image' },
82 | });
83 | await db.userGroup.create({ data: { userId: userSession.userId, groupId: new_group.id, role: 'ADMIN' } });
84 |
85 | if (groupImageUrl) {
86 | await invokeBackgroundTask(`${process.env.DOMAIN}/api/generate-image-caption`, {
87 | groupId: new_group.id,
88 | });
89 | }
90 |
91 | return redirect(`/groups/${new_group.id}`);
92 | }
93 |
94 | export default function GroupNew() {
95 | const { csrfToken } = useLoaderData();
96 | const actionData = useActionData();
97 | return (
98 |
99 |
100 |
101 |
Create New Group
102 |
Your Community Starts Here
103 |
104 |
145 |
146 |
147 |
148 |
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/app/routes/_auth.signup.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
2 | import { redirect } from '@remix-run/node';
3 | import { Form, useActionData, useNavigation } from '@remix-run/react';
4 | import { z } from 'zod';
5 | import { Button } from '~/components/ui/button';
6 | import { Link } from '~/components/ui/links';
7 |
8 | import { Card } from '~/components/ui/containers';
9 | import { Input } from '~/components/ui/forms';
10 | import { getHash } from '~/modules/database/crypto.server';
11 | import { db } from '~/modules/database/db.server';
12 | import { badRequest } from '~/modules/response/response.server';
13 | import { createUserSession, getUserSession } from '~/modules/session/session.server';
14 |
15 | export async function action({ request }: ActionFunctionArgs) {
16 | const signUpFormData = Object.fromEntries(await request.formData());
17 |
18 | const SignUpForm = z.object({
19 | name: z
20 | .string()
21 | .trim()
22 | .min(1, 'Name is required.')
23 | .max(191, 'Name cannot exceed 191 max length.')
24 | .transform((name) => name.replace(/\s+/g, ' ')),
25 | email: z
26 | .string()
27 | .toLowerCase()
28 | .trim()
29 | .email('Invalid email.')
30 | .refine(
31 | async (email) => !(await db.user.findUnique({ where: { email } })),
32 | 'Email already in use. Is this you? Please log in instead.',
33 | ),
34 | password: z
35 | .string()
36 | .min(8, 'Password must be 8 or more characters.')
37 | .max(191, 'Password cannot exceed 191 max length.'),
38 | confirmPassword: z
39 | .string()
40 | .refine((confirmPassword) => confirmPassword === signUpFormData.password, 'Password must match.'),
41 | });
42 |
43 | const signUpForm = await SignUpForm.safeParseAsync(signUpFormData);
44 |
45 | if (!signUpForm.success) {
46 | return badRequest({
47 | success: false,
48 | fields: { name: signUpFormData.name, email: signUpFormData.email },
49 | ...signUpForm.error.flatten(),
50 | });
51 | }
52 |
53 | const newUser = await db.user.create({
54 | data: {
55 | name: signUpForm.data.name,
56 | email: signUpForm.data.email,
57 | },
58 | });
59 |
60 | await db.password.create({
61 | data: {
62 | userId: newUser.id,
63 | hash: await getHash(signUpForm.data.password),
64 | },
65 | });
66 |
67 | const headers = await createUserSession(newUser.id);
68 |
69 | return redirect('/', { headers });
70 | }
71 |
72 | export async function loader({ request }: LoaderFunctionArgs) {
73 | const session = await getUserSession(request);
74 | if (session) {
75 | return redirect('/');
76 | }
77 | return {};
78 | }
79 |
80 | export default function Component() {
81 | const actionData = useActionData();
82 | const navigation = useNavigation();
83 | const isPending = navigation.state === 'submitting' || navigation.state === 'loading';
84 | return (
85 |
177 | );
178 | }
179 |
--------------------------------------------------------------------------------
/app/routes/groups_.$groupId.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
2 | import { useParams, useLoaderData } from '@remix-run/react';
3 | import { db } from '~/modules/database/db.server';
4 | import { json } from '@remix-run/node';
5 | import { LinkButton } from '~/components/ui/links';
6 |
7 | import { Image, staticImage } from '~/components/ui/images';
8 | import { eventsDataPatcher } from '~/modules/events/event';
9 |
10 | export const meta: MetaFunction = ({ data }) => {
11 | const group = data.group;
12 |
13 | return [{ title: `${group.name} | Social Plan-It` }, { name: 'description', content: `${group.description}` }];
14 | };
15 |
16 | export async function loader({ params }: LoaderFunctionArgs): Promise {
17 | const group = await db.group.findFirstOrThrow({
18 | where: { id: params.groupId },
19 | include: {
20 | events: true,
21 | _count: {
22 | select: {
23 | user_groups: true,
24 | },
25 | },
26 | },
27 | });
28 | return json({ group });
29 | }
30 |
31 | export default function GroupRoute() {
32 | const { group } = useLoaderData();
33 | const events = eventsDataPatcher(group.events);
34 | return (
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
{group.name}
51 |
Headline: Group headline here
52 |
53 |
54 |
55 |
63 |
64 |
Organized by: Name Here
65 |
66 |
67 |
68 |
69 |
70 |
78 |
79 |
80 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
103 |
104 |
105 | {group._count.user_groups} Member{group._count.user_groups > 1 ? 's' : ''}
106 |
107 |
108 |
109 |
110 |
118 |
119 |
Location, USA
120 |
121 |
122 |
123 |
131 |
132 |
Share
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
What we are about
141 |
{group.description}
142 |
143 |
144 |
Upcoming Events
145 |
146 | {!group.events || group.events.length === 0 ? (
147 |
148 |
No Upcoming Events scheduled at this time
149 |
150 | ) : (
151 | events.map((event) => {
152 | if (event.date < new Date()) {
153 | return <>>;
154 | }
155 |
156 | return (
157 |
160 | );
161 | })
162 | )}
163 |
164 |
165 |
166 |
167 |
Create New Event
168 |
Back to groups
169 |
170 | );
171 | }
172 |
173 | export function ErrorBoundary() {
174 | const { groupId } = useParams();
175 | return There was an error loading group by the id {groupId}.
;
176 | }
177 |
--------------------------------------------------------------------------------
/app/routes/_auth.login.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
2 | import type { FormEvent } from 'react';
3 |
4 | import { redirect } from '@remix-run/node';
5 | import { Form, useActionData, useNavigation, useSubmit } from '@remix-run/react';
6 | import { startAuthentication } from '@simplewebauthn/browser';
7 | import { useState } from 'react';
8 | import { Card } from '~/components/ui/containers';
9 | import { Input } from '~/components/ui/forms';
10 | import { matchesHash } from '~/modules/database/crypto.server';
11 | import { db } from '~/modules/database/db.server';
12 | import { badRequest } from '~/modules/response/response.server';
13 | import { SignInWithGoogleButton } from '~/modules/session/buttons';
14 | import { verifyGoogleToken } from '~/modules/session/google-auth.server';
15 | import { createUserSession, getUserSession } from '~/modules/session/session.server';
16 | import { verifyPasskeyAuthenticationResponse } from '~/modules/session/webauthn.server';
17 | import { Button } from '~/components/ui/button';
18 | import { KeyIcon } from '~/components/ui/icons';
19 | import { Link } from '~/components/ui/links';
20 |
21 | export async function action({ request }: ActionFunctionArgs) {
22 | const form = await request.formData();
23 | const email = form.get('email');
24 | const password = form.get('password');
25 | const credential = form.get('credential');
26 | const authenticationResponseJson = form.get('authenticationResponseJson');
27 |
28 | if (typeof authenticationResponseJson === 'string' && typeof email === 'string') {
29 | const user = await db.user.findUnique({ where: { email }, include: { authenticators: true } });
30 | if (!user) {
31 | return badRequest({ passkeyError: 'No passkeys exists for this account. Please sign in with password instead.' });
32 | }
33 |
34 | try {
35 | const authenticationResponse = JSON.parse(authenticationResponseJson);
36 | const { verified } = await verifyPasskeyAuthenticationResponse(user, authenticationResponse);
37 | if (verified) {
38 | const headers = await createUserSession(user.id);
39 | return redirect('/', { headers });
40 | }
41 |
42 | return badRequest({ passkeyError: 'Sign in with passkey failed.' });
43 | } catch {
44 | return badRequest({ passkeyError: 'Sign in with passkey failed.' });
45 | }
46 | }
47 |
48 | if (typeof credential === 'string') {
49 | try {
50 | const payload = await verifyGoogleToken(credential);
51 | if (!payload) {
52 | throw new Error('Invalid payload.');
53 | }
54 |
55 | const { name, email } = payload;
56 | if (!name || !email) {
57 | throw new Error('Name or email does not exist in payload.');
58 | }
59 |
60 | const user = await db.user.findUnique({ where: { email: email.trim().toLowerCase() } });
61 |
62 | if (!user) {
63 | return badRequest({ message: 'No user exists with this Google account. Please sign up instead.' });
64 | }
65 |
66 | const headers = await createUserSession(user.id);
67 | return redirect('/', { headers });
68 | } catch (error) {
69 | return badRequest({ message: 'Google authentication failed.' });
70 | }
71 | }
72 |
73 | if (!email || !password) {
74 | return { status: 400, message: 'Missing required fields' };
75 | }
76 | const cleanEmail: string = email.toString().toLowerCase().trim();
77 |
78 | const user = await db.user.findUnique({ where: { email: cleanEmail } });
79 |
80 | if (!user) {
81 | return { status: 400, message: 'Email not in use. Please sign up instead.' };
82 | }
83 |
84 | const passwordObject = await db.password.findUnique({ where: { userId: user.id } });
85 | if (!passwordObject) {
86 | return { status: 400, message: "Credentials don't match. Please try again." };
87 | }
88 |
89 | const passwordCorrect = await matchesHash(password.toString(), passwordObject.hash);
90 |
91 | if (!passwordCorrect) {
92 | return { status: 400, message: "Credentials don't match. Please try again." };
93 | }
94 |
95 | const headers = await createUserSession(user.id);
96 |
97 | return redirect('/', { headers });
98 | }
99 |
100 | export async function loader({ request }: LoaderFunctionArgs) {
101 | const session = await getUserSession(request);
102 | if (session) {
103 | return redirect('/');
104 | }
105 | return {};
106 | }
107 |
108 | export default function Component() {
109 | const actionData = useActionData();
110 | const navigation = useNavigation();
111 | const isPending = navigation.state === 'submitting' || navigation.state === 'loading';
112 |
113 | const submit = useSubmit();
114 |
115 | const [email, setEmail] = useState('');
116 | const [processingPasskey, setProcessingPasskey] = useState(false);
117 | const [passkeyError, setPasskeyError] = useState('');
118 |
119 | async function handleSignInWithPasskey(e: FormEvent) {
120 | e.preventDefault();
121 | setProcessingPasskey(true);
122 | setPasskeyError('');
123 |
124 | try {
125 | const resp = await fetch('/generate-authentication-options', {
126 | method: 'POST',
127 | body: JSON.stringify({ email }),
128 | headers: {
129 | 'Content-Type': 'application/json',
130 | },
131 | });
132 | const options = await resp.json();
133 | if (!options) {
134 | setPasskeyError('No passkeys exists for this account. Please sign in with password instead.');
135 | return;
136 | }
137 |
138 | const authenticationResponse = await startAuthentication(options);
139 | submit({ authenticationResponseJson: JSON.stringify(authenticationResponse), email }, { method: 'POST' });
140 | } catch {
141 | setPasskeyError('Failed to sign in with passkey.');
142 | } finally {
143 | setProcessingPasskey(false);
144 | }
145 | }
146 |
147 | return (
148 |
149 |
150 |
151 |
152 | Log In
153 |
163 |
172 |
173 | {isPending ? 'Logging in...' : 'Log In'}
174 |
175 |
176 |
177 |
178 | or
179 |
180 |
181 | {actionData && 'message' in actionData && {actionData.message}
}
182 |
183 |
184 |
185 | New to Social Plan-It?{' '}
186 |
187 | Join now
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | or
196 |
197 |
198 | setEmail(e.target.value)}
208 | />
209 |
210 |
211 | {processingPasskey || isPending ? 'Signing in...' : 'Sign in with Passkey'}
212 |
213 |
214 | {passkeyError && {passkeyError}
}
215 | {actionData && 'passkeyError' in actionData && {actionData.passkeyError}
}
216 |
217 |
218 |
219 |
220 |
221 | );
222 | }
223 |
--------------------------------------------------------------------------------