diff --git a/.gitignore b/.gitignore index 7431b26..890bec9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ # production /nextjs/matcha/build -.github \ No newline at end of file +.github + +node_modules +.DS_Store \ No newline at end of file diff --git a/fastify/assets/package.json b/fastify/assets/package.json index 3215fe4..dce433a 100644 --- a/fastify/assets/package.json +++ b/fastify/assets/package.json @@ -19,6 +19,7 @@ "image-dimensions": "^2.5.0", "pg": "^8.16.3", "pump": "^3.0.3", + "sharp": "^0.34.5", "text-case": "^1.2.9" }, "devDependencies": { diff --git a/fastify/assets/srcs/app.ts b/fastify/assets/srcs/app.ts index 8a1b7be..154cdc8 100644 --- a/fastify/assets/srcs/app.ts +++ b/fastify/assets/srcs/app.ts @@ -46,7 +46,8 @@ export const buildApp = () => { files: 1, headerPairs: 2000, parts: 1000 - } + }, + attachFieldsToBody: true }); app.register(websocket, { diff --git a/fastify/assets/srcs/controllers/private/me/profilePictures.ts b/fastify/assets/srcs/controllers/private/me/profilePictures.ts index 84536fe..330ecc5 100644 --- a/fastify/assets/srcs/controllers/private/me/profilePictures.ts +++ b/fastify/assets/srcs/controllers/private/me/profilePictures.ts @@ -2,6 +2,15 @@ import { FastifyRequest, FastifyReply, FastifyRequestUser } from 'fastify'; import { AppError, UnauthorizedError, ForbiddenError, NotFoundError, BadRequestError } from '../../../utils/error'; import path from 'path'; import fs from 'fs'; +import sharp from 'sharp'; +import { imageDimensionsFromData } from 'image-dimensions'; + +type Crop = { + x: number; + y: number; + width: number; + height: number; +} export const setProfilePictureIndexHandler = async ( request: FastifyRequest, @@ -33,12 +42,30 @@ export const addProfilePictureHandler = async ( const user = request.user; const userId: number = (user as any)?.id; if (!userId) - throw new UnauthorizedError(); + throw new UnauthorizedError(); + + const rotation = Number((request.body as { rotation?: { value: string } }).rotation?.value) || 0; + const rawCrop = (request.body as { crop?: { value: string } })?.crop?.value; + let crop: Crop | undefined; + if (typeof rawCrop === 'string') { + try { + const parsed = JSON.parse(rawCrop); + crop = parsed && typeof parsed === 'object' ? parsed : undefined; + } catch (e) { + throw new BadRequestError('Invalid crop JSON'); + } + } else if (rawCrop && typeof rawCrop === 'object') { + crop = rawCrop as Crop; + } + const file = request.fileMeta; if (!file) throw (new BadRequestError()); + if (!request.fileBuffer) + throw (new BadRequestError()); + const picturesDir = path.join(__dirname, '..', '..', '..', '..', 'uploads', userId.toString()); if (!fs.existsSync(picturesDir)) { fs.mkdirSync(picturesDir, { recursive: true }); @@ -48,7 +75,25 @@ export const addProfilePictureHandler = async ( const newFilePath = path.join(picturesDir, newFileName); const newFileURL = `https://${process.env.DOMAIN || 'localhost'}/api/private/uploads/${userId}/${newFileName}`; const dest = fs.createWriteStream(newFilePath); - dest.write(request.fileBuffer); + + const currentSize = await imageDimensionsFromData(request.fileBuffer); + if (!currentSize) + throw new BadRequestError('Unrecognized file format'); + + let newBuffer; + try { + newBuffer = await sharp(request.fileBuffer).rotate(rotation || 0).extract({ + left: Math.round(crop?.x || 0), + top: Math.round(crop?.y || 0), + width: Math.round(crop?.width || currentSize.width), + height: Math.round(crop?.height || currentSize.height) + }).toBuffer(); + } catch (error) { + throw new BadRequestError('Failed to process image with given crop/rotation'); + } + + console.log('Saving new profile picture to', newFilePath); + dest.write(newBuffer); dest.end(); // dest.on('finish', () => { diff --git a/fastify/assets/srcs/models/User/index.ts b/fastify/assets/srcs/models/User/index.ts index af5b0bd..5a04278 100644 --- a/fastify/assets/srcs/models/User/index.ts +++ b/fastify/assets/srcs/models/User/index.ts @@ -132,6 +132,7 @@ export default class UserModel { } update = async (id: number, user: UserProfile, location?: UserLocation) => { + console.log("Updating user ID:", id, "with data:", user, "and location:", location); user = this.fixPropertiesCase(user); if (user.bornAt instanceof Date) { user.bornAt = user.bornAt.toISOString(); diff --git a/fastify/assets/srcs/plugins/checkImageConformity.ts b/fastify/assets/srcs/plugins/checkImageConformity.ts index d587b0b..fcc6ba7 100644 --- a/fastify/assets/srcs/plugins/checkImageConformity.ts +++ b/fastify/assets/srcs/plugins/checkImageConformity.ts @@ -1,11 +1,12 @@ import fp from 'fastify-plugin' import { FastifyRequest, FastifyReply } from 'fastify' import { imageDimensionsFromData } from 'image-dimensions' +import { MultipartFile } from '@fastify/multipart' export default fp(async function(fastify, opts) { fastify.decorate("checkImageConformity", async function(request: FastifyRequest, reply: FastifyReply) { try { - const file = await request.file(); + const { file } = request.body as { file?: MultipartFile }; if (!file) throw (new Error('No file uploaded')); @@ -15,34 +16,66 @@ export default fp(async function(fastify, opts) { // read once into a Buffer and reuse const buffer = await file.toBuffer(); + if (!buffer || buffer.length === 0) + throw (new Error('Failed to read uploaded file')); const maxSizeInBytes = 50 * 1024 * 1024; // 50 MB if (buffer.length > maxSizeInBytes) throw (new Error('File size exceeds limit')); + const ratiox = 9; const ratioy = 16; const minWidth = 150; const maxWidth = 1080; - const currentSize = await imageDimensionsFromData(buffer); - if (!currentSize) - throw new Error('Unrecognized file format'); - - // const ratio = (currentSize.width / currentSize.height).toPrecision(3); - const expectedRatio = (ratiox / ratioy).toPrecision(3); - if (currentSize.width > maxWidth || currentSize.width < minWidth - // || ratio != expectedRatio - ) - throw new Error('Wrong file dimensions'); - - // attach buffer + meta to the request so later handlers can reuse it + const currentSize = imageDimensionsFromData(buffer); request.fileBuffer = buffer; request.fileMeta = { filename: file.filename, mimetype: file.mimetype, - fields: file.fields // if you need multipart fields + fields: file.fields, // if you need multipart fields }; + + const rawCrop = (request.body as { crop?: { value: string } })?.crop?.value; + let crop: { width?: number; height?: number; x?: number; y?: number } | undefined; + if (typeof rawCrop === 'string') { + try { + const parsed = JSON.parse(rawCrop); + crop = parsed && typeof parsed === 'object' ? parsed : undefined; + } catch (e) { + throw new Error('Invalid crop JSON'); + } + } else if (rawCrop && typeof rawCrop === 'object') { + crop = rawCrop; + } + + const width = crop?.width != null ? Number(crop.width) : undefined; + const height = crop?.height != null ? Number(crop.height) : undefined; + if (width && height) { + let rotation = Number((request.body as { rotation?: { value: string } }).rotation?.value) || 0; + if (rotation > 180) rotation = 180; + if (rotation < -180) rotation = -180; + const ratio = (width / height).toPrecision(2); + const expectedRatio = (ratiox / ratioy).toPrecision(2); + // console.log('width', width); + // console.log('width > maxWidth', width > maxWidth); + // console.log('width < minWidth', width < minWidth); + // console.log(ratio) + // console.log(expectedRatio) + + if (width > maxWidth || width < minWidth || ratio != expectedRatio) + throw new Error('Wrong file dimensions after crop/rotation'); + return; // Skip other checks + } + + if (!currentSize) + throw new Error('Unrecognized file format'); + + const ratio = (currentSize.width / currentSize.height).toPrecision(2); + const expectedRatio = (ratiox / ratioy).toPrecision(2); + if (currentSize.width > maxWidth || currentSize.width < minWidth || ratio != expectedRatio) + throw new Error('Wrong file dimensions'); } catch (err) { if (err instanceof Error && err.message) return reply.status(400).send({ error: err.message }); diff --git a/fastify/assets/srcs/routes/private/index.ts b/fastify/assets/srcs/routes/private/index.ts index f767cea..f5d62ae 100644 --- a/fastify/assets/srcs/routes/private/index.ts +++ b/fastify/assets/srcs/routes/private/index.ts @@ -24,7 +24,8 @@ export default async function privateRoutes(fastify: FastifyInstance, options: F fastify.register(wsRoutes, { prefix: '/ws', preHandler: fastify.checkIsCompleted }); fastify.register(statics, { root: path.join(__dirname, '../../../uploads'), - prefix: '/uploads/', - decorateReply: false + decorateReply: false, + prefix: '/uploads', // optional: default '/' + // constraints: { host: process.env.HOST || 'localhost' }, // optional: default {} }); } diff --git a/fastify/assets/srcs/routes/private/user/me/completeProfile.ts b/fastify/assets/srcs/routes/private/user/me/completeProfile.ts index 766af0d..cfab1b8 100644 --- a/fastify/assets/srcs/routes/private/user/me/completeProfile.ts +++ b/fastify/assets/srcs/routes/private/user/me/completeProfile.ts @@ -29,10 +29,10 @@ const completeProfileRoutes = async (fastify: FastifyInstance) => { body: { type: 'object', properties: { - firstName: { type: 'string', minLength: 2, maxLength: 50 }, - lastName: { type: 'string', minLength: 2, maxLength: 50 }, - bio: { type: 'string', minLength: 2, maxLength: 100 }, - tags: { type: 'array', items: { type: 'string' }, minItems: 1 }, + firstName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + lastName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + bio: { type: 'string', minLength: 50, maxLength: 500 }, + tags: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 30, pattern: '[a-zA-Z_]' }, minItems: 3 }, gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual', 'other'] }, bornAt: { type: 'string', format: 'date-time' }, diff --git a/fastify/assets/srcs/routes/private/user/me/index.ts b/fastify/assets/srcs/routes/private/user/me/index.ts index c107537..31e1e8d 100644 --- a/fastify/assets/srcs/routes/private/user/me/index.ts +++ b/fastify/assets/srcs/routes/private/user/me/index.ts @@ -10,9 +10,9 @@ const meRoutes = async (fastify: FastifyInstance) => { 'GET /profile', 'PUT /profile', 'DELETE /profile', ]}; }); - fastify.register(profilePictureRoutes, { prefix: '/profile-picture', preHandler: fastify.checkIsCompleted }); + fastify.register(profilePictureRoutes, { prefix: '/profile-picture' }); fastify.register(profileRoutes, { prefix: '/profile', preHandler: fastify.checkIsCompleted }); - fastify.register(completeProfileRoutes, { prefix: '/complete-profile', preHandler: fastify.checkIsVerified }); + fastify.register(completeProfileRoutes, { prefix: '/complete-profile'}); } export default meRoutes; \ No newline at end of file diff --git a/fastify/assets/srcs/routes/private/user/me/profile.ts b/fastify/assets/srcs/routes/private/user/me/profile.ts index 358cae3..75fee9b 100644 --- a/fastify/assets/srcs/routes/private/user/me/profile.ts +++ b/fastify/assets/srcs/routes/private/user/me/profile.ts @@ -4,23 +4,22 @@ import { setProfileHandler, getProfileHandler } from "../../../../controllers/pr const profileRoutes = async (fastify: FastifyInstance) => { fastify.put('/', { schema: { - body: { type: 'object', properties: { - firstName: { type: 'string' }, - lastName: { type: 'string' }, + firstName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, + lastName: { type: 'string', minLength: 1, maxLength: 50, pattern: '[a-zA-Z-\' ]' }, email: { type: 'string', format: 'email' }, - bio: { type: 'string', minLength: 50, maxLength: 100 }, - tags: { type: 'array', items: { type: 'string' }, minItems: 1 }, + bio: { type: 'string', minLength: 50, maxLength: 500 }, + tags: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 30, pattern: '[a-zA-Z_]' }, minItems: 3 }, gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual', 'other'] }, bornAt: { type: 'string', format: 'date-time' }, location: { type: 'object', properties: { - latitude: { type: 'number' }, - longitude: { type: 'number' } + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 } }, additionalProperties: false } @@ -69,7 +68,7 @@ const profileRoutes = async (fastify: FastifyInstance) => { bio: { type: 'string', maxLength: 100 }, tags: { type: 'array', items: { type: 'string' } }, bornAt: { type: 'string', format: 'date-time' }, - gender: { type: 'string', enum: ['male', 'female'] }, + gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual'] }, isVerified: { type: 'boolean' }, isProfileCompleted: { type: 'boolean' }, diff --git a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts index b0b9451..91e61b1 100644 --- a/fastify/assets/srcs/routes/private/user/me/profilePicture.ts +++ b/fastify/assets/srcs/routes/private/user/me/profilePicture.ts @@ -77,6 +77,7 @@ const profilePictureRoutes = async (fastify: FastifyInstance) => { }); fastify.post('/', { schema: { + consumes: ['multipart/form-data'], response: { 200: { type: 'object', diff --git a/fastify/assets/srcs/routes/private/user/view/index.ts b/fastify/assets/srcs/routes/private/user/view/index.ts index 7833381..f1776f0 100644 --- a/fastify/assets/srcs/routes/private/user/view/index.ts +++ b/fastify/assets/srcs/routes/private/user/view/index.ts @@ -23,7 +23,7 @@ const viewRoutes = async (fastify: FastifyInstance) => { bio: { type: 'string', maxLength: 100 }, tags: { type: 'array', items: { type: 'string' } }, bornAt: { type: 'string', format: 'date-time' }, - gender: { type: 'string', enum: ['male', 'female'] }, + gender: { type: 'string', enum: ['men', 'women'] }, orientation: { type: 'string', enum: ['heterosexual', 'homosexual', 'bisexual'] }, fameRate: { type: 'number' }, location: { diff --git a/fastify/assets/srcs/services/UserService.ts b/fastify/assets/srcs/services/UserService.ts index 769bd10..82065b1 100644 --- a/fastify/assets/srcs/services/UserService.ts +++ b/fastify/assets/srcs/services/UserService.ts @@ -166,14 +166,15 @@ class UserService { createdAt: Date; }> { const user = await this.getUser(id); + console.log("Retrieved user:", user); if (!user || !user.isProfileCompleted) throw new NotFoundError(); return { id: user.id, email: user.email, username: user.username, - firstName: user.firstName || '', - lastName: user.lastName || '', + firstName: user.firstName as string, + lastName: user.lastName as string, profilePictureIndex: user.profilePictureIndex, profilePictures: user.profilePictures || [], bio: user.bio || '', @@ -208,8 +209,6 @@ class UserService { throw new NotFoundError(); if (user.isProfileCompleted) throw new BadRequestError('Profile already completed'); - if (user.isVerified === false) - throw new BadRequestError('Email not verified'); await this.userModel.update(id, { ...profile, isProfileCompleted: true diff --git a/nextjs/matcha/.gitignore b/nextjs/matcha/.gitignore index 5ef6a52..2629d47 100644 --- a/nextjs/matcha/.gitignore +++ b/nextjs/matcha/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +package-lock.json diff --git a/nextjs/matcha/next.config.ts b/nextjs/matcha/next.config.ts index 09db9ab..8fe0427 100644 --- a/nextjs/matcha/next.config.ts +++ b/nextjs/matcha/next.config.ts @@ -10,6 +10,7 @@ const nextConfig: NextConfig = { pathname: "/api/private/uploads/**", }, ], + domains: ["localhost", "matcha.fr", "mduvey.matcha.fr"] }, }; diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index a936332..dac7959 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -15,7 +15,9 @@ "framer-motion": "^12.23.24", "next": "16.0.0", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "react-easy-crop": "^5.5.6", + "react-image-crop": "^11.0.10" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/nextjs/matcha/pnpm-lock.yaml b/nextjs/matcha/pnpm-lock.yaml index a4aa384..d1e70a0 100644 --- a/nextjs/matcha/pnpm-lock.yaml +++ b/nextjs/matcha/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) + react-easy-crop: + specifier: ^5.5.6 + version: 5.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@19.2.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -1592,6 +1598,9 @@ packages: node-releases@2.0.26: resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1700,6 +1709,17 @@ packages: peerDependencies: react: ^19.2.0 + react-easy-crop@5.5.6: + resolution: {integrity: sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3623,6 +3643,8 @@ snapshots: node-releases@2.0.26: {} + normalize-wheel@1.0.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -3737,6 +3759,17 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-easy-crop@5.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + normalize-wheel: 1.0.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + react-image-crop@11.0.10(react@19.2.0): + dependencies: + react: 19.2.0 + react-is@16.13.1: {} react@19.2.0: {} diff --git a/nextjs/matcha/public/default-profile.svg b/nextjs/matcha/public/default-profile.svg new file mode 100644 index 0000000..f8b690a Binary files /dev/null and b/nextjs/matcha/public/default-profile.svg differ diff --git a/nextjs/matcha/src/app/(logged)/layout.tsx b/nextjs/matcha/src/app/(logged)/layout.tsx index 4a9fc2f..33d44fa 100644 --- a/nextjs/matcha/src/app/(logged)/layout.tsx +++ b/nextjs/matcha/src/app/(logged)/layout.tsx @@ -8,6 +8,7 @@ import ChatInterface from "@/components/browsing/ChatInterface"; import ChatProfilePanel from "@/components/browsing/ChatProfilePanel"; import { mockMatches, mockConversations, mockMessages, generateMockProfilesWithMetadata } from "@/mocks/browsing_mocks"; import { BrowsingProvider, useBrowsing } from "@/contexts/BrowsingContext"; +import { MeProvider, useMe } from "@/contexts/MeContext"; import { Message } from "@/types/message"; type Tab = "matches" | "messages"; @@ -16,6 +17,9 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { openMatchModal, openConversation, closeConversation, selectedConversationId } = useBrowsing(); + + // useMe here — MeProvider is above LayoutContent in the tree + const { id, username, profilePictureUrls, profilePictureIndex, isLoading: meLoading, error: meError, refresh } = useMe(); const [activeTab, setActiveTab] = useState("matches"); const [allMessages, setAllMessages] = useState>(mockMessages); @@ -28,63 +32,34 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const handleTabChange = useCallback((tab: Tab) => { setActiveTab(tab); - if (tab === "messages") { - // Open first conversation when switching to messages - if (mockConversations.length > 0) { - openConversation(mockConversations[0].id); - } + if (mockConversations.length > 0) openConversation(mockConversations[0].id); } else { - // Close conversation when switching to matches closeConversation(); } }, [openConversation, closeConversation, pathname, router]); const handleMatchClick = useCallback((userId: string) => { openMatchModal(userId); - // Don't navigate - let each page handle the modal display }, [openMatchModal]); const handleSendMessage = useCallback((content: string) => { if (!selectedConversationId) return; - - const newMessage: Message = { - id: `msg-${Date.now()}`, - senderId: "current-user", - content, - timestamp: new Date(), - }; - - setAllMessages((prev) => ({ - ...prev, - [selectedConversationId]: [ - ...(prev[selectedConversationId] || []), - newMessage, - ], - })); + const newMessage: Message = { id: `msg-${Date.now()}`, senderId: "current-user", content, timestamp: new Date() }; + setAllMessages((prev) => ({ ...prev, [selectedConversationId]: [ ...(prev[selectedConversationId] || []), newMessage ] })); }, [selectedConversationId]); - // Get the current conversation data - const selectedConversation = selectedConversationId - ? mockConversations.find((conv) => conv.id === selectedConversationId) - : null; - - const selectedChatUser = - selectedConversation && selectedConversation.userId - ? allProfiles.find((u) => u.id === selectedConversation.userId) - : null; - - const conversationMessages = selectedConversationId - ? allMessages[selectedConversationId as keyof typeof allMessages] || [] - : []; + const selectedConversation = selectedConversationId ? mockConversations.find((conv) => conv.id === selectedConversationId) : null; + const selectedChatUser = selectedConversation && selectedConversation.userId ? allProfiles.find((u) => u.id === selectedConversation.userId) : null; + const conversationMessages = selectedConversationId ? allMessages[selectedConversationId as keyof typeof allMessages] || [] : []; return ( - {/* Left Drawer */} + {/* LeftDrawer receives loading/error state and can render skeleton or error locally */} - {/* Main Content - Show ChatInterface when messages tab is active and conversation is selected */} + {/* Main Content - don't block whole layout; child components handle meLoading/meError as needed */} {activeTab === "messages" && selectedConversationId && selectedChatUser ? ( @@ -117,14 +95,13 @@ function LayoutContent({ children }: { children: React.ReactNode }) { ); } -export default function LoggedLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function LoggedLayout({ children }: Readonly<{ children: React.ReactNode }>) { + // MeProvider wraps the whole area that may need user data, but we don't block rendering return ( - - {children} - + + + {children} + + ); } diff --git a/nextjs/matcha/src/app/(logged)/me/page.tsx b/nextjs/matcha/src/app/(logged)/me/page.tsx index 3a77017..96da1a2 100644 --- a/nextjs/matcha/src/app/(logged)/me/page.tsx +++ b/nextjs/matcha/src/app/(logged)/me/page.tsx @@ -1,3 +1,133 @@ +// 'use client'; + +// import { useMe } from "@/contexts/MeContext"; +// import Stack from "@/components/common/Stack"; +// import TextField from "@/components/common/TextField"; +// import ProfileCard from "@/components/browsing/ProfileCard"; +// import { calculateAge } from "@/lib/searchUtils"; +// import { GENDER_OPTIONS , ORIENTATION_OPTIONS } from "@/constants/onboarding"; +// import { useState, useEffect } from "react"; + +// function onChange(field: string, value: any) { +// console.log(`Field ${field} changed to`, value); +// } + +// export default function MePage() { +// const me = useMe(); + +// const [firstNameError, setFirstNameError] = useState(undefined); +// const [lastNameError, setLastNameError] = useState(undefined); +// const [genderError, setGenderError] = useState(undefined); +// const [birthdateError, setBirthdateError] = useState(undefined); +// const [orientationError, setOrientationError] = useState(undefined); + +// useEffect(() => { +// const trimmedFirstName = me.firstName.trim() +// if (trimmedFirstName === '') { +// setFirstNameError('First name cannot be empty'); +// } else if (trimmedFirstName.length > 50) { +// setFirstNameError('First name cannot be longer than 50 characters'); +// } else { +// setFirstNameError(undefined); +// } + +// const trimmedLastName = me.lastName.trim() +// if (trimmedLastName.length > 50) { +// setLastNameError('Last name cannot be longer than 50 characters'); +// } else if (trimmedLastName === '') { +// setLastNameError('Last name cannot be empty'); +// } else { +// setLastNameError(undefined); +// } + +// if (!me.birthdate || isNaN(me.birthdate.getTime())) { +// setBirthdateError('Invalid birthdate'); +// } else { +// setBirthdateError(undefined); +// } + +// const today = new Date(); +// const heightensAgo = today.setFullYear(today.getFullYear() - 18); + +// if (heightensAgo - (me.birthdate as Date).getTime() < 0) +// { +// setBirthdateError('You must be at least 18 years old'); +// } + +// }, [me.firstName, me.lastName, me.birthdate, me.orientation]); + +// return ( +//
+// +//

My Profile

+// {me.isLoading &&

Loading...

} +// {me.error && ( +//
+//

Error loading profile: {me.error}

+// +//
+// )} +// {!me.isLoading && !me.error && ( <> +// +// +// +// me.setFirstName(e.target.value)} /> +// me.setLastName(e.target.value)} /> +// me.setBirthdate(new Date(e.target.value))} /> +// +// +// )} +//
+// { !me.isLoading && !me.error && ( +//
+//
+// {}} key={String(me.id)} id={String(me.id)} name={me.firstName} age={calculateAge(me.birthdate?.toDateString() as string)} pictureUrl={(me.profilePictureUrls?.length > 0 && me.profilePictureIndex !== null) ? me.profilePictureUrls[me.profilePictureIndex] : '/default-profile.svg'}> +//
+//
+// )} +//
+// ); + "use client"; import { useState, useEffect } from "react"; @@ -15,7 +145,7 @@ import InterestsStep from "@/components/onboarding/steps/InterestsStep"; export default function MyProfilePage() { const router = useRouter(); const queryClient = useQueryClient(); - const { data: profile, isLoading, error } = useMyProfile(); + const { data: profile, isLoading, error, refetch } = useMyProfile(); const [isUpdating, setIsUpdating] = useState(false); const [isUploading, setIsUploading] = useState(false); @@ -53,8 +183,8 @@ export default function MyProfilePage() { const handleSave = () => { setValidationError(null); - if (formData.tags.length < 3) { - setValidationError("Vous devez sélectionner au moins 3 centres d'intérêt."); + if (formData.tags.length < 1) { + setValidationError("Vous devez sélectionner au moins 1 centre d'intérêt."); return; } @@ -62,12 +192,55 @@ export default function MyProfilePage() { setValidationError("Votre bio doit contenir au moins 50 caractères."); return; } + if (formData.bio.trim().length > 500) { + setValidationError("Votre bio doit contenir au maximum 500 caractères."); + return; + } if (!formData.firstName.trim() || !formData.lastName.trim() || !formData.email.trim()) { setValidationError("Veuillez remplir tous les champs obligatoires (Prénom, Nom, Email)."); return; } + if (!formData.firstName.trim().match(/^[a-zA-Z-\' ]+$/)) { + setValidationError("Le prénom contient des caractères invalides."); + return; + } + + if (formData.firstName.trim().length > 50 || formData.firstName.trim().length < 1) { + setValidationError("Le prénom doit contenir entre 1 et 50 caractères."); + return; + } + + if (!formData.lastName.trim().match(/^[a-zA-Z-\' ]+$/)) { + setValidationError("Le nom contient des caractères invalides."); + return; + } + + if (formData.lastName.trim().length > 50 || formData.lastName.trim().length < 1) { + setValidationError("Le nom doit contenir entre 1 et 50 caractères."); + return; + } + + if (!formData.email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) { + setValidationError("L'email est invalide."); + return; + } + + if (formData.bornAt) { + const bornDate = new Date(formData.bornAt); + if (isNaN(bornDate.getTime())) { + setValidationError("La date de naissance est invalide."); + return; + } + const today = new Date(); + const eighteenYearsAgo = today.setFullYear(today.getFullYear() - 18); + if (eighteenYearsAgo - bornDate.getTime() < 0) { + setValidationError("Vous devez avoir au moins 18 ans."); + return; + } + } + const updateData = async () => { setIsUpdating(true); try { diff --git a/nextjs/matcha/src/app/onboarding/page.tsx b/nextjs/matcha/src/app/onboarding/page.tsx index 90698b3..ead4d31 100644 --- a/nextjs/matcha/src/app/onboarding/page.tsx +++ b/nextjs/matcha/src/app/onboarding/page.tsx @@ -68,7 +68,6 @@ export default function OnboardingPage() { try { await submitOnboarding(); - console.log("Onboarding submitted successfully"); router.push("/browsing"); } catch (error: any) { console.error("Failed to submit onboarding:", error); @@ -132,6 +131,8 @@ export default function OnboardingPage() { { updateData({ [field]: value }); if (showValidation) { diff --git a/nextjs/matcha/src/components/browsing/ConversationItem.tsx b/nextjs/matcha/src/components/browsing/ConversationItem.tsx index 5313d32..e7416b0 100644 --- a/nextjs/matcha/src/components/browsing/ConversationItem.tsx +++ b/nextjs/matcha/src/components/browsing/ConversationItem.tsx @@ -23,7 +23,7 @@ export default function ConversationItem({ >
- {name} + {name}
diff --git a/nextjs/matcha/src/components/browsing/LeftDrawer.tsx b/nextjs/matcha/src/components/browsing/LeftDrawer.tsx index 5d78efb..fb4c6ca 100644 --- a/nextjs/matcha/src/components/browsing/LeftDrawer.tsx +++ b/nextjs/matcha/src/components/browsing/LeftDrawer.tsx @@ -12,7 +12,7 @@ type Tab = "matches" | "messages"; interface Match { id: string; name: string; - pictureUrl: string; + pictureUrl: string | null; } interface Conversation { @@ -33,6 +33,9 @@ interface LeftDrawerProps { onConversationClick?: (conversationId: string) => void; activeTab?: Tab; onTabChange?: (tab: Tab) => void; + meLoading: boolean; + meError: string | null; + onRetryMe: () => void; } export default function LeftDrawer({ @@ -43,6 +46,9 @@ export default function LeftDrawer({ onConversationClick, activeTab: controlledActiveTab, onTabChange, + meLoading, + meError, + onRetryMe, }: LeftDrawerProps) { const [internalActiveTab, setInternalActiveTab] = useState("matches"); @@ -72,7 +78,28 @@ export default function LeftDrawer({ + { + meError ? ( + + + {meError} + + + + ) : null} {/* Tabs */} onMatchClick?.(match.id)} /> )) diff --git a/nextjs/matcha/src/components/browsing/MatchCard.tsx b/nextjs/matcha/src/components/browsing/MatchCard.tsx index 68d1cec..d47a619 100644 --- a/nextjs/matcha/src/components/browsing/MatchCard.tsx +++ b/nextjs/matcha/src/components/browsing/MatchCard.tsx @@ -21,7 +21,7 @@ export default function MatchCard({ >
- {name} + {name}
{name} diff --git a/nextjs/matcha/src/components/browsing/ProfileCard.tsx b/nextjs/matcha/src/components/browsing/ProfileCard.tsx index e9713c3..7ea3189 100644 --- a/nextjs/matcha/src/components/browsing/ProfileCard.tsx +++ b/nextjs/matcha/src/components/browsing/ProfileCard.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import Typography from "@/components/common/Typography"; import Stack from "@/components/common/Stack"; @@ -22,7 +23,8 @@ export default function ProfileCard({ > {/* Image */}
- {name}
- Vous + { isLoading ? "Loading..." : error ? `Error` : `Vous: ${username}`} - - + - - - - + + + + + +
); } diff --git a/nextjs/matcha/src/components/common/ImageCropper.tsx b/nextjs/matcha/src/components/common/ImageCropper.tsx new file mode 100644 index 0000000..e3a4b11 --- /dev/null +++ b/nextjs/matcha/src/components/common/ImageCropper.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Cropper from "react-easy-crop"; +import IconButton from "@/components/common/IconButton"; +import Button from "@/components/common/Button"; +import getImageUrl from '@/utils/getImageUrl'; +import type { Area } from '@/utils/cropImage'; + +const CROP_AREA_ASPECT = 9 / 16; + +export default function ImageCropper({ + profilePicture, + additionalPictures, + currentCroppingIndex, + onCropComplete, + submitImage, + zoom, + setZoom, + rotation, + setRotation, + crop, setCrop +}: { + profilePicture: File | null; + additionalPictures: (File | null)[]; + currentCroppingIndex: number; + onCropComplete: (croppedArea: Area, croppedAreaPixels: Area) => void; + submitImage: () => void; + zoom: number; + setZoom: (zoom: number) => void; + rotation: number; + setRotation: (rotation: number) => void; + crop: { x: number; y: number }; + setCrop: (crop: { x: number; y: number }) => void; +}) { + // Get the current image based on index (0 = profile picture, 1+ = additional pictures) + const getCurrentImage = (): File | null => { + if (currentCroppingIndex === 0) { + return profilePicture; + } + return additionalPictures[currentCroppingIndex - 1] || null; + }; + + const imageUrl = getImageUrl(getCurrentImage()) || "none"; + + return (
+
+ +
+
+ setRotation((rotation - 90 + 360) % 360)} + size="small" + aria-label="Rotate left" + > + + + + + setRotation((rotation + 90) % 360)} + size="small" + aria-label="Rotate right" + > + + + + +
+
+ + +
+
); +} diff --git a/nextjs/matcha/src/components/me/Settings.tsx b/nextjs/matcha/src/components/me/Settings.tsx new file mode 100644 index 0000000..9c9dd99 --- /dev/null +++ b/nextjs/matcha/src/components/me/Settings.tsx @@ -0,0 +1,9 @@ +'use client' + +export default function Settings() { + return ( +
+

Settings Page

+
+ ) +} \ No newline at end of file diff --git a/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx b/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx index 0e0559c..f31e568 100644 --- a/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/IdentityStep.tsx @@ -33,6 +33,8 @@ export default function IdentityStep({ const hasFirstNameError = showValidation && !firstName.trim(); const hasLastNameError = showValidation && !lastName.trim(); + const lastNameRegexError = showValidation && !lastName.trim().match(/^[a-zA-Z-']+$/); + const firstNameRegexError = showValidation && !firstName.trim().match(/^[a-zA-Z-']+$/); const hasBirthdayError = showValidation && !birthday; const hasBiographyError = showValidation && (!biography.trim() || biography.trim().length < 50); @@ -46,7 +48,7 @@ export default function IdentityStep({ onChange={(e) => onChange("firstName", e.target.value)} placeholder="Entrez votre prénom" required - error={hasFirstNameError ? "Le prénom est requis" : undefined} + error={(firstNameRegexError ? "Le prénom contient des caractères invalides" : undefined) || (hasFirstNameError ? "Le prénom est requis" : undefined)} /> onChange("lastName", e.target.value)} placeholder="Entrez votre nom de famille" required - error={hasLastNameError ? "Le nom de famille est requis" : undefined} + error={(lastNameRegexError ? "Le nom de famille contient des caractères invalides" : undefined) || (hasLastNameError ? "Le nom de famille est requis" : undefined)} /> diff --git a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx index 1341b4b..ab9bab5 100644 --- a/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx +++ b/nextjs/matcha/src/components/onboarding/steps/PicturesStep.tsx @@ -4,13 +4,35 @@ import { useRef, useState } from "react"; import Typography from "@/components/common/Typography"; import ErrorModal from "@/components/common/ErrorModal"; import { MAX_ADDITIONAL_PICTURES } from "@/constants/onboarding"; +import Cropper from 'react-easy-crop' +import getCroppedImg from '@/utils/cropImage'; +import getImageUrl from '@/utils/getImageUrl'; +import Button from '../../common/Button' +import IconButton from "@/components/common/IconButton"; +import ImageCropper from "@/components/common/ImageCropper"; + +declare type Area = { + width: number; + height: number; + x: number; + y: number; +}; + +declare type ImageSettings = { + rotation: number; + crop: Area; +} + +const CROP_AREA_ASPECT = 9 / 16; interface PicturesStepProps { profilePicture: File | null; additionalPictures: (File | null)[]; + profilePictureSettings: ImageSettings; + additionalPicturesSettings: ImageSettings[]; onChange: ( - field: "profilePicture" | "additionalPictures", - value: File | null | (File | null)[] + field: "profilePicture" | "additionalPictures" | "profilePictureSettings" | "additionalPicturesSettings", + value: File | null | (File | null)[] | ImageSettings | ImageSettings[] ) => void; showValidation?: boolean; } @@ -18,6 +40,8 @@ interface PicturesStepProps { export default function PicturesStep({ profilePicture, additionalPictures, + profilePictureSettings, + additionalPicturesSettings, onChange, showValidation = false, }: PicturesStepProps) { @@ -111,6 +135,7 @@ export default function PicturesStep({ } onChange("profilePicture", file); + setCurrentCroppingIndex(0); }; const handleAdditionalPictureChange = (index: number, file: File | null) => { @@ -136,6 +161,7 @@ export default function PicturesStep({ const newPictures = [...additionalPictures]; newPictures[index] = file; onChange("additionalPictures", newPictures); + setCurrentCroppingIndex(index + 1); }; const removeProfilePicture = () => { @@ -152,12 +178,61 @@ export default function PicturesStep({ } }; - const getImageUrl = (file: File | null): string | null => { - return file ? URL.createObjectURL(file) : null; - }; - const hasError = showValidation && !profilePicture; + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [rotation, setRotation] = useState(0); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const [croppedImages, setCroppedImages] = useState([]); + const [currentCroppingIndex, setCurrentCroppingIndex] = useState(null); + + const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels) + } + + const submitImage = async () => { + try { + if (currentCroppingIndex === null || croppedAreaPixels === null) return; + + const picture = currentCroppingIndex == 0 ? profilePicture : additionalPictures[currentCroppingIndex - 1]; + if (picture == null) return; + + const pictureURL = getImageUrl(picture); + if (pictureURL == null) return; + + if (croppedAreaPixels.width < 150) + return showError("La largeur minimale est de 150 pixels"); + if (croppedAreaPixels.width > 1080) + return showError("La largeur maximale est de 1080 pixels"); + + const croppedImage = await getCroppedImg( + pictureURL, + croppedAreaPixels as Area, + rotation + ) + const newCroppedImages = [...croppedImages]; + newCroppedImages[currentCroppingIndex] = croppedImage as string; + setCroppedImages(newCroppedImages); + setCurrentCroppingIndex(null); + onChange( + currentCroppingIndex == 0 ? "profilePictureSettings" : "additionalPicturesSettings", + currentCroppingIndex == 0 + ? { rotation, crop: croppedAreaPixels as Area } + : (() => { + const newAdditionalPictures = [...additionalPicturesSettings]; + newAdditionalPictures[currentCroppingIndex - 1] = { rotation, crop: croppedAreaPixels as Area }; + return newAdditionalPictures; + })() + ); + setRotation(0); + setZoom(1); + } catch (e) { + console.error(e) + } + } + return (
{/* Profile Picture */} @@ -174,10 +249,10 @@ export default function PicturesStep({ )} -
+
Profile @@ -247,6 +322,19 @@ export default function PicturesStep({ )}
+ {currentCroppingIndex !== null && }
@@ -273,7 +361,7 @@ export default function PicturesStep({ {picture ? ( <> {`Additional diff --git a/nextjs/matcha/src/components/profile/ProfileView.tsx b/nextjs/matcha/src/components/profile/ProfileView.tsx index da47cb2..5f3ebc3 100644 --- a/nextjs/matcha/src/components/profile/ProfileView.tsx +++ b/nextjs/matcha/src/components/profile/ProfileView.tsx @@ -102,7 +102,7 @@ export default function ProfileView({
- {profile.gender === "male" ? "Homme" : profile.gender === "female" ? "Femme" : "Autre"} + {profile.gender === "men" ? "Homme" : profile.gender === "women" ? "Femme" : "Autre"} {profile.firstName} {age > 0 ? age : "?"} @@ -185,7 +185,7 @@ export default function ProfileView({ Genre - {profile.gender === "male" ? "Homme" : profile.gender === "female" ? "Femme" : "Autre"} + {profile.gender === "men" ? "Homme" : profile.gender === "women" ? "Femme" : "Autre"}
@@ -217,7 +217,7 @@ export default function ProfileView({
{profile.interestedInGenders.map((gender, idx) => ( - {gender === "male" ? "Homme" : gender === "female" ? "Femme" : "Autre"} + {gender === "men" ? "Homme" : gender === "women" ? "Femme" : "Autre"} {idx < profile.interestedInGenders.length - 1 && ", "} ))} diff --git a/nextjs/matcha/src/constants/onboarding.ts b/nextjs/matcha/src/constants/onboarding.ts index 8627d62..e393be4 100644 --- a/nextjs/matcha/src/constants/onboarding.ts +++ b/nextjs/matcha/src/constants/onboarding.ts @@ -59,11 +59,15 @@ export const AVAILABLE_INTERESTS = [ ]; export const GENDER_OPTIONS = [ - { value: "male", label: "Homme" }, - { value: "female", label: "Femme" }, - { value: "non-binary", label: "Non-binaire" }, - { value: "other", label: "Autre" }, + { value: "men", label: "Homme" }, + { value: "women", label: "Femme" }, ]; +export const ORIENTATION_OPTIONS = [ + { value: "heterosexual", label: "Hétérosexuel" }, + { value: "homosexual", label: "Homosexuel" }, + { value: "bisexual", label: "Bisexuel" }, +] + export const MIN_INTERESTS = 3; export const MAX_ADDITIONAL_PICTURES = 4; diff --git a/nextjs/matcha/src/contexts/MeContext.tsx b/nextjs/matcha/src/contexts/MeContext.tsx new file mode 100644 index 0000000..94367d3 --- /dev/null +++ b/nextjs/matcha/src/contexts/MeContext.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { createContext, useContext, ReactNode, useState, useEffect } from "react"; +import { Location } from "../types/location"; +import axios, { Axios } from "axios"; +import { useQuery } from "@tanstack/react-query"; + +interface MeContextType { + id: number; + setId: (id: number) => void; + profilePictureUrls: string[]; + setProfilePictureUrls: (urls: string[]) => void; + profilePictureIndex: number | null; + setProfilePictureIndex: (index: number | null) => void; + bio: string; + setBio: (bio: string) => void; + tags: string[]; + setTags: (tags: string[]) => void; + email: string; + setEmail: (email: string) => void; + username: string; + setUsername: (username: string) => void; + firstName: string; + setFirstName: (firstName: string) => void; + lastName: string; + setLastName: (lastName: string) => void; + birthdate: Date | null; + setBirthdate: (birthdate: Date | null) => void; + location: Location | null; + setLocation: (location: Location | null) => void; + orientation: string; + setOrientation: (orientation: string) => void; + gender: string; + setGender: (gender: string) => void; + isVerified: boolean; + setIsVerified: (isVerified: boolean) => void; + isProfileCompleted: boolean; + setIsProfileCompleted: (isProfileCompleted: boolean) => void; + fameRate: number; + setFameRate: (fameRate: number) => void; + + error: string | null; + isLoading: boolean; + refresh: () => void; +} + +const MeContext = createContext(undefined); + +type Me = { + id: number; + email: string; + username: string; + firstName: string; + lastName: string; + profilePictures: string[]; + profilePictureIndex: number | null; + bio: string; + tags: string[]; + bornAt: string; + gender: string; + orientation: string; + isVerified: boolean; + isProfileCompleted: boolean; + fameRate: number; + location: Location | null; + createdAt: string; +} + +function usePosts() { + const query = useQuery({ + queryKey: ['me'], + queryFn: async (): Promise => { + const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/private/user/me/profile`, { + withCredentials: true, + }); + return response.data; + }, + }); + if (query.error) { + query.error = new Error("Failed to fetch user profile"); + } + return query; +} + +export function MeProvider({ children }: { children: ReactNode }) { + const [id, setId] = useState(0); + const [profilePictureUrls, setProfilePictureUrls] = useState([]); + const [profilePictureIndex, setProfilePictureIndex] = useState(null); + const [bio, setBio] = useState(""); + const [tags, setTags] = useState([]); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [birthdate, setBirthdate] = useState(null); + const [location, setLocation] = useState(null); + const [orientation, setOrientation] = useState(""); + const [gender, setGender] = useState(""); + const [isVerified, setIsVerified] = useState(false); + const [isProfileCompleted, setIsProfileCompleted] = useState(false); + const [fameRate, setFameRate] = useState(0); + + const { data, error, isLoading, refetch } = usePosts(); + + useEffect(() => { + if (data) { + setId(data.id); + setEmail(data.email); + setUsername(data.username); + setFirstName(data.firstName); + setLastName(data.lastName); + setProfilePictureUrls(data.profilePictures); + setProfilePictureIndex(data.profilePictureIndex); + setBio(data.bio); + setTags(data.tags); + setBirthdate(new Date(data.bornAt)); + setLocation(data.location); + setOrientation(data.orientation); + setGender(data.gender); + } + }, [data]); + + return ( + + {children} + + ); +} + +export function useMe() { + const context = useContext(MeContext); + if (context === undefined) { + throw new Error("useMe must be used within a MeProvider"); + } + return context; +} diff --git a/nextjs/matcha/src/hooks/useOnboarding.ts b/nextjs/matcha/src/hooks/useOnboarding.ts index b43342b..fa7b8c3 100644 --- a/nextjs/matcha/src/hooks/useOnboarding.ts +++ b/nextjs/matcha/src/hooks/useOnboarding.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { OnboardingData, OnboardingStep } from '@/types/onboarding'; import { STEPS, MIN_INTERESTS } from '@/constants/onboarding'; import { profileApi } from '@/lib/api/profile'; +import { Fira_Sans_Extra_Condensed } from 'next/font/google'; const initialData: OnboardingData = { firstName: '', @@ -13,6 +14,11 @@ const initialData: OnboardingData = { interestedInGenders: [], profilePicture: null, additionalPictures: [null, null, null, null], + profilePictureSettings: { + rotation: 0, + crop: { x: 0, y: 0, width: 0, height: 0 }, + }, + additionalPicturesSettings: [], }; export const useOnboarding = () => { @@ -50,9 +56,13 @@ export const useOnboarding = () => { return !!( data.firstName.trim() && data.lastName.trim() && + data.firstName.trim().length >= 1 && data.firstName.trim().length <= 50 && + data.lastName.trim().length >= 1 && data.lastName.trim().length <= 50 && data.birthday && data.biography.trim() && - data.biography.trim().length >= 50 + data.biography.trim().length >= 50 && data.biography.trim().length <= 500 && + data.firstName.trim().match(/^[a-zA-Z-\']+$/) && + data.lastName.trim().match(/^[a-zA-Z-\']+$/) ); case 'interests': return data.interests.length >= MIN_INTERESTS; @@ -81,28 +91,44 @@ export const useOnboarding = () => { const completedStepsCount = STEPS.filter((step) => isStepValid(step.id)).length; const submitOnboarding = async () => { - console.log("Submitting onboarding with data:", data); - - // Step 1: Upload profile picture (required) - if (!data.profilePicture) { - throw new Error('Profile picture is required'); - } - - await profileApi.uploadProfilePicture(data.profilePicture); + try { + // Step 1: Upload profile picture (required) + if (data.profilePicture) { + const formData = new FormData(); + formData.append('file', data.profilePicture); + formData.append('rotation', data.profilePictureSettings.rotation.toString()); + formData.append('crop', JSON.stringify(data.profilePictureSettings.crop)); + + await fetch('/api/private/user/me/profile-picture', { + method: 'POST', + body: formData, + }); + // await profileApi.uploadProfilePicture(data.profilePicture); + } - // Step 2: Upload additional pictures - for (const picture of data.additionalPictures) { - if (picture) { - await profileApi.uploadProfilePicture(picture); + for (const picture of data.additionalPictures) { + if (picture) { + const formData = new FormData(); + formData.append('file', picture); + formData.append('rotation', data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].rotation.toString()); + formData.append('crop', JSON.stringify(data.additionalPicturesSettings[data.additionalPictures.indexOf(picture)].crop)); + + await fetch('/api/private/user/me/profile-picture', { + method: 'POST', + body: formData, + }); + // await profileApi.uploadProfilePicture(picture); + } } - } // Map gender from UI values to backend enum ("men"/"women") const genderMap: Record = { 'Men': 'men', 'Women': 'women', 'male': 'men', - 'female': 'women' + 'female': 'women', + 'men': 'men', + 'women': 'women', }; // Step 3: Complete profile with onboarding data @@ -118,24 +144,28 @@ export const useOnboarding = () => { ? 'homosexual' as const : 'heterosexual' as const, bornAt: new Date(data.birthday).toISOString(), - - }; - - const response = await profileApi.completeProfile(profileData); + }; + const response = await profileApi.completeProfile(profileData); - // Update location with default values (Paris) - try { await profileApi.updateProfile({ location: { latitude: 48.8566, longitude: 2.3522 } }); + + + if (!response) { + // const error = await response.json(); + + console.error('Failed to submit onboarding:'); + throw new Error( 'Failed to submit onboarding'); + } + + return response; } catch (error) { console.error("Failed to update default location:", error); } - - return response; }; return { diff --git a/nextjs/matcha/src/lib/api/browsing.ts b/nextjs/matcha/src/lib/api/browsing.ts index 4a999fc..c86bdb2 100644 --- a/nextjs/matcha/src/lib/api/browsing.ts +++ b/nextjs/matcha/src/lib/api/browsing.ts @@ -25,7 +25,6 @@ export const browsingApi = { let userProfile; try { userProfile = await profileApi.getMyProfile(); - console.log("Fetched user profile for defaults:", userProfile); } catch (error) { console.error('Failed to fetch user profile for defaults', error); } diff --git a/nextjs/matcha/src/mocks/browsing_mocks.ts b/nextjs/matcha/src/mocks/browsing_mocks.ts index 38b116f..dc5c8c9 100644 --- a/nextjs/matcha/src/mocks/browsing_mocks.ts +++ b/nextjs/matcha/src/mocks/browsing_mocks.ts @@ -3,7 +3,7 @@ import { FilterOptions } from "@/components/browsing/FilterBar/types"; import { filterProfiles } from "@/lib/searchUtils"; // Available profile pictures -const FEMALE_PICTURES = [ +const female_PICTURES = [ "/mock_pictures/femme1.jpg", "/mock_pictures/femme2.jpg", "/mock_pictures/femme3.jpg", @@ -11,7 +11,7 @@ const FEMALE_PICTURES = [ "/mock_pictures/femme5.jpg", ]; -const MALE_PICTURES = [ +const men_PICTURES = [ "/mock_pictures/homme1.jpg", "/mock_pictures/homme2.jpg", "/mock_pictures/homme3.jpg", @@ -29,7 +29,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Passionnée de voyages et de photographie. J'adore découvrir de nouvelles cultures et partager des moments authentiques.", interests: ["Voyages", "Photographie", "Cuisine", "Yoga"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme1.jpg", additionalPictures: ["/mock_pictures/femme2.jpg", "/mock_pictures/femme3.jpg", null, null], fame: 85, @@ -43,7 +43,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Architecte le jour, artiste la nuit. J'aime créer et explorer l'art sous toutes ses formes.", interests: ["Architecture", "Art", "Musique", "Randonnée"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme2.jpg", additionalPictures: ["/mock_pictures/femme1.jpg", null, null, null], fame: 62, @@ -57,7 +57,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Étudiante en médecine et amoureuse des animaux. Je cherche quelqu'un avec qui partager de bons moments.", interests: ["Médecine", "Animaux", "Lecture", "Sport"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme3.jpg", additionalPictures: ["/mock_pictures/femme4.jpg", "/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg", null], fame: 45, @@ -71,7 +71,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Développeuse web passionnée par les nouvelles technologies et l'innovation.", interests: ["Technologie", "Gaming", "Cinéma", "Running"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme4.jpg", additionalPictures: ["/mock_pictures/femme5.jpg", null, null, null], fame: 15, @@ -85,7 +85,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Professeure de danse et amoureuse de la vie. Toujours prête pour de nouvelles aventures !", interests: ["Danse", "Musique", "Fitness", "Mode"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme5.jpg", additionalPictures: ["/mock_pictures/femme1.jpg", "/mock_pictures/femme2.jpg", null, null], fame: 0, @@ -99,7 +99,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Étudiante en communication, passionnée par les réseaux sociaux et le marketing digital.", interests: ["Marketing", "Réseaux sociaux", "Café", "Voyage"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme1.jpg", additionalPictures: [null, null, null, null], fame: 0, @@ -113,7 +113,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Chef cuisinière qui aime expérimenter de nouvelles saveurs. Gourmande assumée !", interests: ["Cuisine", "Gastronomie", "Vin", "Pâtisserie"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme2.jpg", additionalPictures: ["/mock_pictures/femme3.jpg", "/mock_pictures/femme4.jpg", "/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg"], fame: 0, @@ -127,7 +127,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Graphiste freelance et amoureuse de la nature. Je cherche quelqu'un de créatif et authentique.", interests: ["Design", "Nature", "Randonnée", "Photographie"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme3.jpg", additionalPictures: ["/mock_pictures/femme2.jpg", null, null, null], fame: 0, @@ -141,7 +141,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Infirmière dévouée le jour, aventurière le week-end. J'adore les activités en plein air.", interests: ["Sport", "Nature", "Camping", "Vélo"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme4.jpg", additionalPictures: ["/mock_pictures/femme5.jpg", "/mock_pictures/femme1.jpg", null, null], fame: 0, @@ -155,7 +155,7 @@ export const mockUserProfiles: UserProfile[] = [ biography: "Musicienne et compositrice. La musique est ma vie et je cherche quelqu'un qui partage cette passion.", interests: ["Musique", "Concert", "Guitare", "Chant"], gender: "female", - interestedInGenders: ["male"], + interestedInGenders: ["men"], profilePicture: "/mock_pictures/femme5.jpg", additionalPictures: ["/mock_pictures/femme3.jpg", null, null, null], fame: 0, @@ -367,11 +367,11 @@ export function generateMockUserProfiles(n: number): UserProfile[] { bioTemplates[Math.floor(Math.random() * bioTemplates.length)]; // Random gender and preferences - const gender = Math.random() > 0.5 ? "female" : "male"; - const interestedInGenders = gender === "female" ? ["male"] : ["female"]; + const gender = Math.random() > 0.5 ? "female" : "men"; + const interestedInGenders = gender === "female" ? ["men"] : ["female"]; // Select pictures based on gender - const picturePool = gender === "female" ? FEMALE_PICTURES : MALE_PICTURES; + const picturePool = gender === "female" ? female_PICTURES : men_PICTURES; const profilePicture = picturePool[Math.floor(Math.random() * picturePool.length)]; // Generate random additional pictures (0-4 pictures, some can be null) diff --git a/nextjs/matcha/src/types/location.ts b/nextjs/matcha/src/types/location.ts new file mode 100644 index 0000000..0f52c7b --- /dev/null +++ b/nextjs/matcha/src/types/location.ts @@ -0,0 +1,6 @@ +export type Location = { + latitude: number; + longitude: number; + city: string; + country: string; +}; \ No newline at end of file diff --git a/nextjs/matcha/src/types/onboarding.ts b/nextjs/matcha/src/types/onboarding.ts index e5673d3..c01f79a 100644 --- a/nextjs/matcha/src/types/onboarding.ts +++ b/nextjs/matcha/src/types/onboarding.ts @@ -15,6 +15,14 @@ export type OnboardingData = { // Step 4: Pictures profilePicture: File | null; additionalPictures: (File | null)[]; + profilePictureSettings: { + rotation: number; + crop: { x: number; y: number; width: number; height: number }; + }; + additionalPicturesSettings: { + rotation: number; + crop: { x: number; y: number; width: number; height: number }; + }[]; }; export type OnboardingStep = diff --git a/nextjs/matcha/src/utils/cropImage.ts b/nextjs/matcha/src/utils/cropImage.ts new file mode 100644 index 0000000..66bdd5e --- /dev/null +++ b/nextjs/matcha/src/utils/cropImage.ts @@ -0,0 +1,105 @@ +/** + * Create a cropped image from a source image URL + */ +export const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export function getRadianAngle(degreeValue: number): number { + return (degreeValue * Math.PI) / 180; +} + +export function rotateSize(width: number, height: number, rotation: number): { width: number; height: number } { + const rotRad = getRadianAngle(rotation); + return { + width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + }; +} + +export interface Area { + width: number; + height: number; + x: number; + y: number; +} + +export type ImageFormat = 'image/jpeg' | 'image/png' | 'image/webp'; + +export interface CropOptions { + format?: ImageFormat; + quality?: number; +} + +/** + * Returns a cropped image as a data URL + * @param imageSrc - Source image URL + * @param pixelCrop - Crop area in pixels + * @param rotation - Rotation in degrees (0-360) + * @param flip - Flip options + * @param options - Output format and quality options + */ +export default async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false }, + options: CropOptions = {} +): Promise { + const { format = 'image/jpeg', quality = 0.9 } = options; + + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + const rotRad = getRadianAngle(rotation); + + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width, + image.height, + rotation + ); + + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; + + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad); + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); + + ctx.drawImage(image, 0, 0); + + const croppedCanvas = document.createElement('canvas'); + const croppedCtx = croppedCanvas.getContext('2d'); + + if (!croppedCtx) { + return null; + } + + croppedCanvas.width = pixelCrop.width; + croppedCanvas.height = pixelCrop.height; + + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + return croppedCanvas.toDataURL(format, quality); +} diff --git a/nextjs/matcha/src/utils/getImageUrl.ts b/nextjs/matcha/src/utils/getImageUrl.ts new file mode 100644 index 0000000..d598760 --- /dev/null +++ b/nextjs/matcha/src/utils/getImageUrl.ts @@ -0,0 +1,6 @@ +/** + * Convert a File object to a data URL for display + */ +export default function getImageUrl(file: File | null): string | null { + return file ? URL.createObjectURL(file) : null; +}