diff --git a/src/app/api/restart-challenge/restart-challenge-schema.ts b/src/app/api/restart-challenge/restart-challenge-schema.ts new file mode 100644 index 00000000..12cac428 --- /dev/null +++ b/src/app/api/restart-challenge/restart-challenge-schema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const restartChallengeSchema = z.object({ + userId: z.string().uuid(), +}); \ No newline at end of file diff --git a/src/app/api/restart-challenge/route.ts b/src/app/api/restart-challenge/route.ts new file mode 100644 index 00000000..13b63fd9 --- /dev/null +++ b/src/app/api/restart-challenge/route.ts @@ -0,0 +1,38 @@ +import 'server-only'; +import { NextResponse, type NextRequest } from 'next/server'; +import { restartChallengeSchema } from './restart-challenge-schema'; +import { serverContainer } from '@/services/server/container'; +import { SERVER_SERVICE_KEYS } from '@/services/server/keys'; +import { ServerError } from '@/errors/server-error'; + +export async function PUT(request: NextRequest) { + const auth = serverContainer.get(SERVER_SERVICE_KEYS.Auth); + const userRepository = serverContainer.get(SERVER_SERVICE_KEYS.UserRepository); + + try { + const data = await request.json(); + const { userId } = restartChallengeSchema.parse(data); + + const user = await auth.loadSessionUser(); + // impossible that frontend and cookie issue. + if (!user || user.uid !== userId) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + //sign in so no issue in the user id . please improve it!!!!!! + const newTimestamp = await userRepository.restartChallenge(userId); + + return NextResponse.json( + { challengeEndTimestamp: newTimestamp }, + { status: 200 }, + ); + } catch (e) { + if (e instanceof ServerError) { + return NextResponse.json({ error: e.message }, { status: e.statusCode }); + } + + return NextResponse.json({ error: 'Bad data.' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/components/button/button.module.scss b/src/components/button/button.module.scss new file mode 100644 index 00000000..f4db8f98 --- /dev/null +++ b/src/components/button/button.module.scss @@ -0,0 +1,81 @@ +// button.module.scss +@use '../../styles/partials/variables/font-families'; +@use '../../styles/partials/variables/font-weights'; +@use '../../styles/partials/variables/letter-spacings'; +@use '../../styles/partials/variables/font-sizes'; +@use '../../styles/partials/variables/colors'; +@use '../../styles/partials/variables/gradients'; + +@mixin -base { + position: relative; + display: inline-block; + font-family: font-families.$bebas-neue; + font-weight: font-weights.$regular; + letter-spacing: letter-spacings.$large; + text-align: center; + text-decoration: none; + text-transform: uppercase; + border-style: solid; + border-radius: 40px; + border-width: 4px; + border-color: colors.$black-8by8; +} + +@mixin -outline { + &::after { + content: ""; + position: absolute; + border-radius: 40px; + top: -8px; + left: -8px; + right: -8px; + bottom: -8px; + border: colors.$white 4px solid; + } +} + +@mixin -gradient-text { + :first-child { + // a span element should be added inside the button to contain the text, these styles will apply to it + background: gradients.$yellow-teal; + background-clip: text; + -webkit-background-clip: text; + -moz-background-clip: text; + -webkit-text-fill-color: transparent; + -moz-text-fill-color: transparent; + color: transparent; + font-size: inherit; + } +} + +.btn_gradient { + @include -base; + @include -outline; + background: gradients.$yellow-teal; + color: colors.$black-8by8; +} +.custom-font-size { + font-size: 20px; // 你可以根据需要调整字体大小 +} +.btn_inverted { + @include -base; + @include -gradient-text; + @include -outline; + background: colors.$black-8by8; +} + +.btn_lg { + font-size: font-sizes.$lg; + height: 64px; + border-radius: 40px; + border-width: 4px; +} + +.btn_sm { + font-size: font-sizes.$md; + padding: 4px 14px; +} + +.btn_wide { + width: 100%; +} diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx new file mode 100644 index 00000000..585939ba --- /dev/null +++ b/src/components/button/button.tsx @@ -0,0 +1,27 @@ +// Button.tsx +import React, { FC } from 'react'; +import styles from './button.module.scss'; + +type ButtonProps = { + variant?: 'btn_gradient' | 'btn_inverted'; + size?: 'btn_lg' | 'btn_sm'; + wide?: boolean; + children: React.ReactNode; +}; + +const Button: FC = ({ variant = 'btn_gradient', size = 'btn_lg', wide = false, children }) => { + const classNames = [styles[variant], styles[size]]; + if (wide) { + classNames.push(styles.btn_wide); + } + + console.log('Button classNames:', classNames); // Debugging line + + return ( + + ); +}; + +export default Button; diff --git a/src/components/button/index.tsx b/src/components/button/index.tsx new file mode 100644 index 00000000..d5a78232 --- /dev/null +++ b/src/components/button/index.tsx @@ -0,0 +1 @@ +export { default as Button } from './button'; \ No newline at end of file diff --git a/src/contexts/user-context/restart-challenge-modal.tsx b/src/contexts/user-context/restart-challenge-modal.tsx new file mode 100644 index 00000000..01a40811 --- /dev/null +++ b/src/contexts/user-context/restart-challenge-modal.tsx @@ -0,0 +1,43 @@ +"use client"; +import { Modal } from "@/components/utils/modal"; +import { Button } from "@/components/utils/button"; +import { useContextSafely } from "@/hooks/use-context-safely"; +import { UserContext } from "@/contexts/user-context"; +import { calculateDaysRemaining } from "@/app/progress/calculate-days-remaining"; +import { useState } from "react"; + +export function RestartChallengeModal() { + const [isLoading, setLoading] = useState(false); + + const { restartChallenge, user } = useContextSafely(UserContext, "RestartChallengeModal"); + if (!user) return null; + const showModal = calculateDaysRemaining(user) === 0; + const restartAndChangeisLoading = async () => { + try{ + setLoading(true); + await restartChallenge(); + } catch (error) {} + finally { + setLoading(false); + } + } + return ( + {}}> + {isLoading ? ( +

Restarting your challenge...

+ ) : ( + <> +

Oops, times up! But no worries, restart your challenge to continue!

+ + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/services/server/user-repository/supabase-user-repository.ts b/src/services/server/user-repository/supabase-user-repository.ts index 92ff6924..2df74be0 100644 --- a/src/services/server/user-repository/supabase-user-repository.ts +++ b/src/services/server/user-repository/supabase-user-repository.ts @@ -7,7 +7,7 @@ import type { UserRepository } from './user-repository'; import type { User } from '@/model/types/user'; import type { CreateSupabaseClient } from '../create-supabase-client/create-supabase-client'; import type { IUserRecordParser } from '../user-record-parser/i-user-record-parser'; - +import { DateTime } from 'luxon'; /** * An implementation of {@link UserRepository} that interacts with * a [Supabase](https://supabase.com/) database and parses rows returned from @@ -25,7 +25,18 @@ export const SupabaseUserRepository = inject( private createSupabaseClient: CreateSupabaseClient, private userRecordParser: IUserRecordParser, ) {} - + async restartChallenge(userId: string): Promise { + const supabase = this.createSupabaseClient(); + const updatedChallengeEndTimestamp = DateTime.now().plus({ days: 8 }).toUnixInteger(); + const { error } = await supabase.from('users').update({ challenge_end_timestamp: updatedChallengeEndTimestamp }).eq('id', userId); + + if(error) { + throw new ServerError('Failed to update user.', 500); + } + + return updatedChallengeEndTimestamp; + //follow the style of getUserById AND supabase->throw an error + } async getUserById(userId: string): Promise { const supabase = this.createSupabaseClient(); diff --git a/src/services/server/user-repository/user-repository.ts b/src/services/server/user-repository/user-repository.ts index 9d259a6d..44a9e348 100644 --- a/src/services/server/user-repository/user-repository.ts +++ b/src/services/server/user-repository/user-repository.ts @@ -8,4 +8,5 @@ export interface UserRepository { makeHybrid(userId: string): Promise; awardRegisterToVoteBadge(userId: string): Promise; awardElectionRemindersBadge(userId: string): Promise; + restartChallenge(userId: string): Promise; } diff --git a/src/stories/components/restart-challenge/restart.stories.tsx b/src/stories/components/restart-challenge/restart.stories.tsx new file mode 100644 index 00000000..3510e20e --- /dev/null +++ b/src/stories/components/restart-challenge/restart.stories.tsx @@ -0,0 +1,57 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { GlobalStylesProvider } from '@/stories/global-styles-provider'; +import { RestartChallengeModal } from '@/contexts/user-context/restart-challenge-modal'; +import { Builder } from 'builder-pattern'; +import { UserContext,type UserContextType } from '@/contexts/user-context'; +import type { User } from '@/model/types/user'; +import { PropsWithChildren, useState } from 'react'; +import { time } from 'console'; +import { promise } from 'zod'; +import { DateTime } from 'luxon'; +const meta: Meta = { + component: RestartChallengeModal, +}; + +export default meta; + +type Story = StoryObj; + +const createUserContext = () => { + const user: User = Builder().uid("1").challengeEndTimestamp(0).build(); +}; +function UserContextProvider({ children } : PropsWithChildren) { + const [user, setUser] = useState(Builder().uid("1").challengeEndTimestamp(0).build()); + const restartChallenge = async () => { + return new Promise (resolve=>{ + setTimeout(() => { + setUser(Builder().uid("1").challengeEndTimestamp(DateTime.now().plus({days: 8}).toMillis()).build()); + resolve(); + },3000); }); + }; + return ( + () + .user(user) + .restartChallenge(restartChallenge) + .build()} + > + {children} + + ); + + +} +export const RestartChallenge: Story = { + + render: () => { + + + return ( + + + + + + ); + }, +}; +