diff --git a/package.json b/package.json index bad50a9..d7f78b8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "axios": "^1.4.0", "bcrypt": "^5.1.0", "bull": "^4.10.4", + "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", @@ -31,6 +32,7 @@ "mongoose": "^7.3.0", "multer": "^1.4.5-lts.1", "multer-s3": "^3.0.1", + "nodemailer": "^6.9.3", "passport": "^0.6.0", "passport-local": "^1.0.0" } diff --git a/src/main.js b/src/main.js index b71a60d..c9f322b 100644 --- a/src/main.js +++ b/src/main.js @@ -18,7 +18,9 @@ const aiAPI = process.env.AI_API const allowedOrigins = [ 'http://localhost:3000', - 'http://www.model-fit.kro.kr', + 'http://localhost:3001', + process.env.WEB_API, + process.env.WEB_IP, aiAPI, ] @@ -60,9 +62,6 @@ app.use(express.static(path.join(__dirname, '../style/build'))) app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../style/build/index.html')) }) -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../style/build/index.html')) -}) const usersRouter = require('./routes/RegisterUser') const loginRouter = require('./routes/LoginUser') @@ -70,3 +69,22 @@ const logoutRouter = require('./routes/LogoutUser') app.use('/users', usersRouter) app.use('/users', loginRouter) app.use('/users', logoutRouter) + +const authRouter = require('./routes/MailAuthRouter') +app.use('/auth', authRouter) + +// userInfo 조회 +const infoRouter = require('./routes/UserInfo') +app.use('/userInfo', infoRouter) + +const fittingRouter = require('./routes/FittingImage') +const clothUploadRouter = require('./routes/UploadClothImage') +app.use('/api', fittingRouter) +app.use('/api', clothUploadRouter) + +const clothRouter = require('./routes/Clothes') +app.use('/cloth', clothRouter) + +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../style/build/index.html')) +}) diff --git a/src/models/Closet.js b/src/models/Closet.js new file mode 100644 index 0000000..85228dc --- /dev/null +++ b/src/models/Closet.js @@ -0,0 +1,11 @@ +const mongoose = require('mongoose') +const { Schema } = mongoose + +const closetSchema = new Schema({ + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + clothesUrl: { type: String, required: true }, + clothesImageLink: { type: String, required: true }, + fittingImageLink: { type: String }, +}) + +module.exports = mongoose.model('Closet', closetSchema) diff --git a/src/models/SizeProfile.js b/src/models/SizeProfile.js index 8351027..256673d 100644 --- a/src/models/SizeProfile.js +++ b/src/models/SizeProfile.js @@ -2,10 +2,10 @@ const mongoose = require('mongoose') const { Schema } = mongoose const sizeProfileSchema = new Schema({ - userId: { type: Schema.Types.ObjectId, ref: 'User' }, - length: { type: Number }, - shoulderWidth: { type: Number }, - chestWidth: { type: Number }, + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + length: { type: Number, required: true }, + shoulderWidth: { type: Number, required: true }, + chestWidth: { type: Number, required: true }, }) module.exports = mongoose.model('SizeProfile', sizeProfileSchema) diff --git a/src/models/User.js b/src/models/User.js index df14feb..8dabd45 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -9,13 +9,14 @@ const userSchema = new Schema({ gender: { type: String, required: true }, height: { type: Number, required: true }, weight: { type: Number, required: true }, - file: { type: String }, + file: { type: String, required: true }, favoriteStyle: { style: { type: String, default: '' }, color: { type: String, default: '' }, fit: { type: String, default: '정핏' }, }, sizeProfile: { type: Schema.Types.ObjectId, ref: 'SizeProfile' }, + clothes: [{ type: Schema.Types.ObjectId, ref: 'Clothes' }], }) module.exports = mongoose.model('User', userSchema) diff --git a/src/modules/AuthMail/AuthMail.js b/src/modules/AuthMail/AuthMail.js new file mode 100644 index 0000000..3809ef7 --- /dev/null +++ b/src/modules/AuthMail/AuthMail.js @@ -0,0 +1,43 @@ +const nodemailer = require('nodemailer') +const generateAuthCode = require('./generateAuthCode') + +/* + 구글에서는 from 바꾸는 부분 동작하지 않음 + 참고 : https://nodemailer.com/usage/using-gmail/ +*/ +const noreply = 'noreply@model-fit.kro.kr' + +async function sendAuthMail(email) { + try { + // SMTP 전송을 위한 transporter 생성 + const transporter = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: process.env.AUTH_BOT_MAIL, + pass: process.env.AUTH_BOT_PASS, + }, + }) + + // 이메일 옵션 설정 + const code = generateAuthCode() + const mailOptions = { + from: `AuthBot<${noreply}>`, + to: email, + subject: 'The authorization code for model.fit.', + text: `code : ${code}`, + } + + // 이메일 전송 + const info = await transporter.sendMail(mailOptions) + console.log(`Email sent successfully : ${email}`) + return { + authCode: code, + result: { success: true, code: 'SEND_DONE', errno: 0 }, + } + } catch (error) { + console.error(`Email sent failed : ${email}`) + return { success: false, code: error.code, errno: -1 } + } +} + +module.exports = sendAuthMail diff --git a/src/modules/AuthMail/generateAuthCode.js b/src/modules/AuthMail/generateAuthCode.js new file mode 100644 index 0000000..7943f8d --- /dev/null +++ b/src/modules/AuthMail/generateAuthCode.js @@ -0,0 +1,13 @@ +const crypto = require('crypto') + +// 6자리의 숫자로만 구성된 난수 생성 +function generateAuthCode() { + const byteLength = 4 + + const buffer = crypto.randomBytes(byteLength) + const randomCode = parseInt(buffer.toString('hex'), 16).toString().slice(0, 6) + + return randomCode +} + +module.exports = generateAuthCode diff --git a/src/routes/.FittingImage.js.swp b/src/routes/.FittingImage.js.swp new file mode 100644 index 0000000..e65d924 Binary files /dev/null and b/src/routes/.FittingImage.js.swp differ diff --git a/src/routes/Clothes.js b/src/routes/Clothes.js new file mode 100644 index 0000000..82b0618 --- /dev/null +++ b/src/routes/Clothes.js @@ -0,0 +1,67 @@ +const express = require('express') +const router = express.Router() +const Clothes = require('../models/Closet') +const User = require('../models/User') +const { getClothesImageUrls } = require('./crawling') +const { deleteImageFromS3 } = require('./ImageUploader') + +router.get('/api/clothes', async (req, res) => { + const { userId } = req.query + try { + const user = await User.findById(userId) + if (!user) { + return res + .status(404) + .json({ success: false, message: 'The user was not found.' }) + } + const clothes = await Clothes.find({ userId: userId }) + res.json( + clothes.map((cloth) => ({ + clothesImageLink: cloth.clothesImageLink, + _id: cloth._id, + })) + ) + } catch (err) { + console.error(err.message) + res.status(500).send('Server Error') + } +}) + +// 입력받은 url에서 이미지 링크 검색 후 반환 +router.get('/api/clothesFromUrl', async (req, res) => { + const { url } = req.query + + const result = await getClothesImageUrls(url) + if (result.success) { + console.log('success') + return res.status(200).json(result) + } else { + return res.status(500).json(result) + } +}) + +router.delete('/api/delete/:id', async (req, res) => { + const clothId = req.params.id + try { + const cloth = await Clothes.findById(clothId) + if (!cloth) { + return res.status(404).json({ error: 'No cloth found for given id' }) + } + if (cloth.clothesImageLink && cloth.clothesImageLink.trim() !== '') { + console.log(cloth.clothesImageLink) + await deleteImageFromS3(cloth.clothesImageLink) + } + + if (cloth.fittingImageLink && cloth.fittingImageLink.trim() !== '') { + console.log(cloth.fittingImageLink) + await deleteImageFromS3(cloth.fittingImageLink) + } + + await Clothes.findByIdAndDelete(clothId) + res.status(200).json({ message: 'Cloth deleted successfully' }) + } catch (error) { + res.status(400).json({ error: 'Error while deleting the cloth' }) + } +}) + +module.exports = router diff --git a/src/routes/FittingImage.js b/src/routes/FittingImage.js new file mode 100644 index 0000000..c596053 --- /dev/null +++ b/src/routes/FittingImage.js @@ -0,0 +1,92 @@ +const express = require('express') +const router = express.Router() +const axios = require('axios') +const https = require('https') +const User = require('../models/User') +const Clothes = require('../models/Closet') +const fittingApiUrl = process.env.AI_FITTING_API_URL + +async function isImageValid(imageUrl) { + return new Promise((resolve, reject) => { + const options = { + method: 'HEAD', + url: imageUrl, + }; + + const req = https.request(imageUrl, options, (res) => { + if (res.statusCode === 200) { + resolve(true); + } else { + resolve(false); + } + }); + + req.on('error', (error) => { + console.error(`Error checking image URL: ${imageUrl}`, error); + resolve(false); + }); + + req.end(); + }); +} +router.post('/fitting', async (req, res) => { + try { + const { userId, clothID } = req.body + + // 사용자 이미지 가져오기 + const user = await User.findOne({ _id: userId }) + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + const userImageUrl = user.file + const cloth = await Clothes.findOne({ _id: clothID }) + if (!cloth) { + return res.status(404).json({ error: 'Cloth not found' }) + } + const clothImageUrl = cloth.clothesImageLink + + // 이미 피팅이미지가 있으면 + if (cloth && cloth.fittingImageLink) { + const isValid = await isImageValid(cloth.fittingImageLink) + if (isValid) { + return res + .status(200) + .json({ fittingImageLink: cloth.fittingImageLink }) + } else { + console.warn( + 'Stored fittingImageLink is not valid. Fetching new image.' + ) + } + } + const response = await axios.post(fittingApiUrl, null, { + headers: { + 'Content-Type': 'application/json', + }, + params: { + ID: userId, + image_url: userImageUrl, + cloth_url: clothImageUrl, + }, + }) + if (response.data.success) { + console.log('Fitting request sent successfully.') + + const imageUrl = response.data.file_name + await Clothes.updateOne( + { userId, clothesImageLink: clothImageUrl }, + { $set: { fittingImageLink: imageUrl } } + ) + + res.status(200).json({ fittingImageLink: imageUrl }) + } else { + console.log(error.response) + console.error('Error:', response.data.error) + res.status(500).json({ error: response.data.error }) + } + } catch (error) { + console.error('Fitting Request Error:', error) + res.status(500).json({ error: 'Fitting Request Error' }) + } +}) + +module.exports = router diff --git a/src/routes/ImageUploader.js b/src/routes/ImageUploader.js index cd4b9e3..2905d8a 100644 --- a/src/routes/ImageUploader.js +++ b/src/routes/ImageUploader.js @@ -2,8 +2,10 @@ const aws = require('@aws-sdk/client-s3') const multer = require('multer') const multers3 = require('multer-s3') const path = require('path') +const { ObjectId } = require('mongodb') +const url = require('url') -const { S3Client, PutObjectCommand } = aws +const { S3Client, PutObjectCommand, DeleteObjectCommand } = aws const s3 = new S3Client({ region: 'us-east-2', @@ -26,13 +28,32 @@ const ImageUploader = multer({ s3: s3, bucket: 'bigprogect-bucket', key: (req, file, callback) => { - const uploadDirectory = req.query.directory ?? 'images' + /* + 회원가입 시 신체 이미지 업로드 하면 아직 DB에 컬럼이 없음. + => userId 필드는 무조건 undefined가 됨. + => s3/undefined/에 이미지가 저장 됨 + => id를 생성할 수 있게 [ ?? new ObjectId().toString() ] 추가 + */ + const userId = + (req.body.userId || req.query.userId) ?? new ObjectId().toString() + const uploadDirectory = req.query.directory ?? userId const extension = path.extname(file.originalname) if (!allowedExtensions.includes(extension)) { return callback(new Error('wrong extension')) } - const userId = req.query.userId - callback(null, `${uploadDirectory}/${userId}/userimage${extension}`) + + const imageType = req.query.type + let imageFilename + + if (imageType === 'body') { + imageFilename = `userimage${extension}` + } else if (imageType === 'clothing') { + imageFilename = file.originalname + } else { + return callback(new Error('Unknown image type')) + } + + callback(null, `${uploadDirectory}/${imageFilename}`) }, acl: 'public-read-write', }), @@ -42,4 +63,20 @@ const ImageUploader = multer({ }, }) -module.exports = ImageUploader +// 이미지 삭제 +const deleteImageFromS3 = async (imageUrl) => { + const imageKey = decodeURIComponent(new URL(imageUrl).pathname.substring(1)) + const params = { + Bucket: 'bigprogect-bucket', + Key: imageKey, + } + + try { + const deleteObject = new DeleteObjectCommand(params) + await s3.send(deleteObject) + } catch (error) { + console.error('Error deleting image from S3:', error) + } +} + +module.exports = { ImageUploader, deleteImageFromS3 } diff --git a/src/routes/MailAuthRouter.js b/src/routes/MailAuthRouter.js new file mode 100644 index 0000000..a77fabd --- /dev/null +++ b/src/routes/MailAuthRouter.js @@ -0,0 +1,43 @@ +const express = require('express') +const router = express.Router() +const sendAuthMail = require('../modules/AuthMail/AuthMail') + +const authStore = new Map() + +// 인증 저장소에 이메일-인증값 저장 +const addAuth = async (email, authCode) => { + authStore.set(email, authCode) + await new Promise((resolve) => + setTimeout(resolve, process.env.MAIL_AUTH_TIME) + ) + if (authStore.has(email)) authStore.delete(email) +} + +// 인증 저장소에 저장된 값으로 인증 확인 +const checkAuth = (email, authCode) => { + if (!authStore.has(email)) return false + else if (authStore.get(email) !== authCode) return false + else { + authStore.delete(email) + return true + } +} + +router.post('/api/sendMail', async (req, res) => { + const { email } = req.query + const { authCode, result } = await sendAuthMail(email) + if (result.success) { + addAuth(email, authCode) + res.status(200).json(result) + } else res.status(500).json(result) +}) + +router.post('/api/mailAuthCheck', async (req, res) => { + const { email, authCode } = req.query + if (checkAuth(email, authCode)) + res.status(200).json({ success: true, code: 'AUTH_CHECK_DONE', errno: 0 }) + else + res.status(200).json({ success: false, code: 'AUTH_CHECK_FAIL', errno: -1 }) +}) + +module.exports = router diff --git a/src/routes/RegisterUser.js b/src/routes/RegisterUser.js index 5fdeeaa..5eea910 100644 --- a/src/routes/RegisterUser.js +++ b/src/routes/RegisterUser.js @@ -4,89 +4,130 @@ const bcrypt = require('bcrypt') const User = require('../models/User') const SizeProfile = require('../models/SizeProfile') const jwt = require('jsonwebtoken') -const ImageUploader = require('./ImageUploader') +const { ImageUploader } = require('./ImageUploader') const axios = require('axios') - -router.post('/register', ImageUploader.single('file'), async (req, res) => { - const { - email: userEmail, - name, - phoneNumber, - password, - gender, - height, - weight, - favoriteStyle, - } = req.body - const file = req.file ? req.file.location : undefined - - try { - const userExists = await User.findOne({ email: userEmail }) - - if (userExists) { - return res.status(400).json({ msg: '이미 가입된 이메일입니다.' }) - } - - // 비밀번호 해시화 - const hashedPassword = await bcrypt.hash(password, 10) - - // 새로운 사용자 생성 및 저장 - const newUser = new User({ +const aiSizeApi = process.env.AI_SIZE_API_URL + +router.post( + '/register', + (req, res, next) => { + req.query.type = 'body' + next() + }, + ImageUploader.single('file'), + async (req, res) => { + const { email: userEmail, name, phoneNumber, - password: hashedPassword, + password, gender, height, weight, - file, favoriteStyle, - }) - - await newUser.save() - - const port = process.env.PORT - let webAPI - - if (process.env.NODE_ENV === 'development') { - webAPI = `http://localhost:${port}` - } else { - webAPI = 'http://www.model-fit.kro.kr' + } = req.body + const file = req.file ? req.file.location : undefined + // 저장한 파일 경로에서 userID 추출 + const userId = req.file.key.split('/')[0] + + try { + const userExists = await User.findOne({ email: userEmail }) + + if (userExists) { + return res.status(400).json({ error: '이미 가입된 이메일입니다.' }) + } + + // 비밀번호 해시화 + const hashedPassword = await bcrypt.hash(password, 10) + + // 새로운 사용자 생성 및 저장 + const newUser = new User({ + _id: userId, + email: userEmail, + name, + phoneNumber, + password: hashedPassword, + gender, + height, + weight, + file, + favoriteStyle, + }) + + await newUser.save() + + const port = process.env.PORT + let webAPI + + if (process.env.NODE_ENV === 'development') { + webAPI = `http://localhost:${port}` + } else { + webAPI = process.env.WEB_API + } + console.log('before parse!') + + // 사용자이미지 전처리 human parse + const aiApiParseResponse = await axios.post( + process.env.AI_PARSE_API, + null, + { + params: { ID: userId, image_url: file }, + } + ) + + console.log('parse complete!') + + if (aiApiParseResponse.data.error) { + console.error('Error from AI API:', aiApiParseResponse.data.error) + res.status(500).json({ success: false, error: 'AI API failed.' }) + return + } + + // 사이즈 받아오기 + const responseFromAIApi = await axios.get(aiSizeApi, { + params: { height: height, weight: weight }, + }) + + if (responseFromAIApi.data.error) { + console.error('Error from AI API:', responseFromAIApi.data.error) + res.status(500).json({ success: false, error: 'AI API failed.' }) + return + } + + const { length, shoulderWidth, chestWidth } = responseFromAIApi.data.size + + // 사이즈 프로필 생성 및 저장 + const newSizeProfile = new SizeProfile({ + userId: newUser._id, + length, + shoulderWidth, + chestWidth, + }) + + await newSizeProfile.save() + + // 사용자 정보 업데이트 + await User.findByIdAndUpdate(newUser._id, { + sizeProfile: newSizeProfile._id, + }) + + // 자동로그인 + const { _id, email } = newUser + const jwt_secret = process.env.JWT_SECRET + const token = jwt.sign({ _id, email }, jwt_secret, { expiresIn: '1h' }) + + return res.status(201).json({ + success: true, + message: '가입 및 로그인 성공!', + user: { _id, email }, + token: token, + }) + } catch (error) { + console.error('Registration Error:', error) + res.status(500).json({ success: false, error: 'Registration failed.' }) } - - // 사이즈 받아오기 - const aiAPI = process.env.AI_API - const aiApiEndpoint = `${aiAPI}/${AI_SIZE_ENDPOINT}` - const responseFromAIApi = await axios.post(aiApiEndpoint, { - imageUrl: file, - height, - weight, - userId: newUser._id, - callbackUrl: `${webAPI}/api/size`, - }) - - if (responseFromAIApi.data.error) { - console.error('Error from AI API:', responseFromAIApi.data.error) - res.status(500).json({ success: false, error: 'AI API failed.' }) - return - } - - // 자동로그인 - const { _id, email } = newUser - const jwt_secret = process.env.JWT_SECRET - const token = jwt.sign({ _id, email }, jwt_secret, { expiresIn: '1h' }) - - return res.status(201).json({ - success: true, - message: '가입 및 로그인 성공!', - user: { _id, email }, - token: token, - }) - } catch (error) { - console.error('Registration Error:', error) - res.status(500).json({ success: false, error: 'Registration failed.' }) } -}) +) // 사이즈 정보 받아오기 router.post('/api/size', async (req, res) => { diff --git a/src/routes/UploadClothImage.js b/src/routes/UploadClothImage.js new file mode 100644 index 0000000..d068e55 --- /dev/null +++ b/src/routes/UploadClothImage.js @@ -0,0 +1,64 @@ +const express = require('express') +const router = express.Router() +const Closet = require('../models/Closet') +const { ImageUploader } = require('./ImageUploader') + +router.post( + '/cloth-upload', + (req, res, next) => { + req.query.type = 'clothing' + ImageUploader.single('clothingImage')(req, res, (error) => { + if (error) { + return res.status(400).json({ success: false, error: error.message }) + } + next() + }) + }, + async (req, res) => { + // 유효성 검사: 요청 userId 확인 + if (!req.body.userId) { + return res.status(400).json({ + success: false, + error: 'Missing userId in request.', + }) + } + + const userId = req.body.userId + // 이미지 업로드 확인 + if (!req.file || !req.file.location) { + return res.status(500).json({ + success: false, + error: 'Image upload failed.', + }) + } + + // 이미지 URL을 생성 + const clothingImageUrl = req.file.location + + // 새 Closet 생성 및 저장 + try { + const newCloset = new Closet({ + userId: userId, + clothesUrl: req.body.clothesUrl, + clothesImageLink: clothingImageUrl, + }) + + const savedCloset = await newCloset.save() + + // 성공적으로 저장된 경우 응답 + res.status(200).json({ + success: true, + message: 'Closet entry created successfully.', + savedCloset, + }) + } catch (error) { + console.error('Error saving to Closet:', error) + res.status(500).json({ + success: false, + error: 'Error saving to Closet.', + }) + } + } +) + +module.exports = router diff --git a/src/routes/UserInfo.js b/src/routes/UserInfo.js new file mode 100644 index 0000000..bda9abc --- /dev/null +++ b/src/routes/UserInfo.js @@ -0,0 +1,160 @@ +// 사이즈: backend -> frontend +const express = require('express') +const router = express.Router() + +const mongoose = require('mongoose') +const { Schema } = mongoose + +const axios = require('axios') + +const User = require('../models/User') +const SizeProfile = require('../models/SizeProfile') + +const sizeAPI = process.env.AI_SIZE_API_URL + +// 사이즈 : backend -> frontend +router.get('/api/size', async (req, res) => { + console.log('get /userInfo/api/size') + const { userId } = req.query + try { + const sizeProfile = await SizeProfile.findOne({ userId: userId }) + res.status(201).json({ success: true, message: sizeProfile }) + } catch (error) { + console.error('Error finding size :', error) + res.status(500).json({ success: false, error: 'Failed to fetch size.' }) + } +}) + +router.get('/api/info', async (req, res) => { + console.log('get /userInfo/api/info') + + const { userId } = req.query + + try { + const user = await User.findById({ _id: userId }) + if (!user) { + return res + .status(404) + .json({ success: false, message: 'The user was not found.' }) + } + res.status(201).json({ success: true, user }) + } catch (error) { + console.error('User lookup errors :', error) + res.status(500).json({ + success: false, + message: 'An error occurred during user lookup.', + }) + } +}) + +// 사용자 정보 업데이트 +router.put('/api/privacy', async (req, res) => { + console.log('put /userInfo/api/privacy') + const user = req.body + const userId = user.userId + + const update = { + $set: user, + } + + try { + const result = await User.findByIdAndUpdate({ _id: userId }, update) + + res.status(201).json({ success: true, code: 'UPDATE_DONE', errno: 0 }) + } catch (error) { + res.status(500).json({ + success: false, + code: error.codeName, + errno: error.code, + /* message: 'An error occurred while updating the document.', */ + }) + } +}) + +// 사이즈 정보 업데이트 +router.put('/api/size', async (req, res) => { + console.log('put /userInfo/api/size') + const reqSize = req.body + const userId = reqSize.userId + + let isShoulderWidthNull = reqSize.shoulderWidth ? false : true + let isChestWidthNull = reqSize.chestWidth ? false : true + let isLengthNull = reqSize.length ? false : true + + let sizeData = { + shoulderWidth: reqSize.shoulderWidth, + chestWidth: reqSize.chestWidth, + length: reqSize.length, + } + let userData = { height: reqSize.height, weight: reqSize.weight } + let sizeResponse + + // 하나라도 null인 경우 size api 사용해서 값 가져옴 + if (isShoulderWidthNull || isChestWidthNull || isLengthNull) { + sizeResponse = await axios.get(sizeAPI, { + params: userData, + }) + // null인 값 sizeRes로 채워줌 + if (isShoulderWidthNull) + sizeData.shoulderWidth = sizeResponse.data.size.shoulderWidth + if (isChestWidthNull) + sizeData.chestWidth = sizeResponse.data.size.chestWidth + if (isLengthNull) sizeData.length = sizeResponse.data.size.length + + if (sizeResponse.data.error) { + console.error('Error from AI API:', sizeResponse.data.error) + res + .status(500) + .json({ success: false, code: 'SIZE_API_FAILED', errno: -1 }) + return + } + } + + try { + const userUpdateRes = await User.findByIdAndUpdate( + { _id: userId }, + { + $set: userData, + }, + { new: true } + ) + const sizeUpdateRes = await SizeProfile.findOneAndUpdate( + { userId: userId }, + { + $set: sizeData, + }, + { new: true } + ) + + res.status(201).json({ success: true, code: 'UPDATE_DONE', errno: 0 }) + } catch (error) { + res.status(500).json({ + success: false, + code: error.codeName, + errno: error.code, + }) + } +}) + +// 사용자 신체 이미지 경로 전송 +router.get('/api/userimage', async (req, res) => { + const { userId } = req.query + + try { + const user = await User.findById(userId) + if (!user) { + return res + .status(404) + .json({ success: false, message: 'The user was not found.' }) + } + res.status(200).json({ success: true, image: user.file }) + } catch (error) { + console.error('User lookup errors :', error) + res.status(500).json({ + success: false, + message: 'An error occurred during user lookup.', + }) + } +}) + +module.exports = router diff --git a/src/routes/crawling.js b/src/routes/crawling.js new file mode 100644 index 0000000..da28614 --- /dev/null +++ b/src/routes/crawling.js @@ -0,0 +1,111 @@ +const axios = require('axios') +const cheerio = require('cheerio') + +const getHtml = async (url) => { + try { + const response = await axios.get(url) + return { success: true, code: 'FETCH_DONE', data: response.data } + } catch (error) { + return { success: false, code: error.code, errno: error.errno } + } +} + +// 60.jpg -> 500.jpg로 바꾸기 위한 함수 +// 뒤부터 문자열 탐색 후 교체 +function replaceLastOccurrence(str, find, replace) { + const lastIndex = str.lastIndexOf(find) + if (lastIndex === -1) { + return str + } + const before = str.substring(0, lastIndex) + const after = str.substring(lastIndex + find.length) + return before + replace + after +} + +// 무신사용 크롤러 +const extractImgSrcFromMusinsa = (html) => { + let imageSrcList = [] + const $ = cheerio.load(html) + + $('ul.product_thumb img').each((index, element) => { + const src = $(element).attr('src') + if (checkImageExtension(src)) { + const modifiedSrc = 'https:' + replaceLastOccurrence(src, '60', '500') + imageSrcList.push(modifiedSrc) + } + }) + + $('div.detail_product_info_item img').each((index, element) => { + const src = $(element).attr('src') + // 상품 상세보기 페이지의 이미지는 확장자 없는 경우도 있음... + // if (!checkImageExtension(src)) { + // return true + // } + if (!src.startsWith('//')) { + imageSrcList.push(src) + } + }) + + return imageSrcList +} + +// 일반 웹사이트용 크롤러 +const extractImgSrc = (html) => { + let imageSrcList = [] + const $ = cheerio.load(html) + + $('img').each((index, element) => { + const src = $(element).attr('src') + // 이미지 확장자로 사진 걸러주는 부분 + // => 확장자로 끝나지 않는 이미지도 꽤 많아 제거 + // if (!checkImageExtension(src)) { + // return true + // } + if (src.startsWith('//')) { + imageSrcList.push('https:' + src) + } else if (!src.startsWith(':data')) { + imageSrcList.push(src) + } + }) + + return imageSrcList +} + +// 파일 이름이 이미지 확장자인지 확인 +function checkImageExtension(url) { + // const imageExtensions = /\.(jpg|jpeg|png|gif|bmp)$/i + const imageExtensions = /\.(jpg|jpeg|png)$/i + return imageExtensions.test(url) +} + +// 메인 크롤러 +const getClothesImageUrls = async (url) => { + // http로 시작하는지 체크 + if (!url.startsWith('http')) { + return { success: false, code: 'URL_ERROR_HTTP', errno: -1 } + } + + // 이미지의 url을 바로 올렸을 경우 체크 + if (checkImageExtension(url)) { + const response = await axios.get(url) + if (response.status === 200) { + return { success: true, code: 'FETCH_DONE', urls: [url] } + } else { + return { success: false, code: 'URL_ERROR_INVALID_IMAGE', errno: -1 } + } + } + const result = await getHtml(url) + if (!result.success) { + return result + } + const html = result.data + if (url.includes('musinsa')) { + const imageSrcList = extractImgSrcFromMusinsa(html) + return { success: true, code: 'FETCH_DONE', urls: imageSrcList, errno: 0 } + } else { + const imageSrcList = extractImgSrc(html) + return { success: true, code: 'FETCH_DONE', urls: imageSrcList, errno: 0 } + } +} + +module.exports = { getClothesImageUrls } diff --git a/style/babel-plugin-macros.config.js b/style/babel-plugin-macros.config.js new file mode 100644 index 0000000..76dc1ec --- /dev/null +++ b/style/babel-plugin-macros.config.js @@ -0,0 +1,5 @@ +module.exports = { + 'fontawesome-svg-core': { + license: 'free', + }, +} diff --git a/style/babelrc.config.js b/style/babelrc.config.js new file mode 100644 index 0000000..1f893cd --- /dev/null +++ b/style/babelrc.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + // api.cache.forever() + api.cache(true) + return { + plugins: ['macros'], + } +} diff --git a/style/package.json b/style/package.json index b27ab55..3a903b9 100644 --- a/style/package.json +++ b/style/package.json @@ -3,20 +3,27 @@ "version": "0.1.0", "private": true, "dependencies": { + "@fortawesome/fontawesome-free": "^6.4.0", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-regular-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.4.0", + "babel-plugin-macros": "^3.1.0", "bootstrap": "^5.3.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", + "prop-types": "^15.8.1", "react": "^18.2.0", "react-bootstrap": "^2.7.4", "react-dom": "^18.2.0", "react-redux": "^8.1.1", "react-router-dom": "^6.13.0", - "react-scripts": "5.0.1", + "react-scripts": "^5.0.1", "react-tooltip": "^5.16.1", "redux": "^4.2.1", "styled-components": "^6.0.0-rc.3", diff --git a/style/src/App.js b/style/src/App.js index 3e06774..9008a59 100644 --- a/style/src/App.js +++ b/style/src/App.js @@ -1,18 +1,34 @@ -import React from 'react' +import React, { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { login } from './features/authSlices' import MainPage from './components/MainPage/MainPage' -import FittingPage from './components/FittingPage.js' -import RecommendPage from './components/RecommendPage.js' +import FittingPage from './components/FittingPage/FittingPage.js' +import RecommendPage from './components/RecommendPage/RecommendPage.js' import PolicyTerms from './components/Terms/PolicyTerms.js' import PrivacyMain from './components/Terms/PrivacyMain.js' import 'react-bootstrap' import { Routes, Route } from 'react-router-dom' +import MyPage from './components/MyPage/MyPage.js' function App() { + const dispatch = useDispatch() + + const isAuthenticated = useSelector((state) => state.auth.isAuthenticated) + const lsUser = JSON.parse(localStorage.getItem('user')) + const lsToken = localStorage.getItem('token') + + useEffect(() => { + if (!isAuthenticated && lsUser && lsToken) { + dispatch(login({ user: lsUser, token: lsToken })) + } + }, [dispatch]) + return ( } /> } /> } /> + } /> } /> } /> diff --git a/style/src/api/apiConfig.js b/style/src/api/apiConfig.js index e74c1f2..f613f72 100644 --- a/style/src/api/apiConfig.js +++ b/style/src/api/apiConfig.js @@ -1,3 +1,3 @@ -export const API_URL = 'http://localhost:3000' +//export const API_URL = 'http://localhost:3000' // 배포 시, 위 코드 주석처리 후 다음 코드로 변경 -// export const API_URL = 'http://www.model-fit.kro.kr' + export const API_URL = 'http://model-fit.kro.kr' diff --git a/style/src/api/authenticatedAxios.js b/style/src/api/authenticatedAxios.js index cce5bf3..d68457f 100644 --- a/style/src/api/authenticatedAxios.js +++ b/style/src/api/authenticatedAxios.js @@ -8,6 +8,11 @@ authenticatedAxios.interceptors.request.use( if (token) { config.headers['Authorization'] = `Bearer ${token}` } + + if (config.data instanceof FormData) { + config.headers['Content-Type'] = 'multipart/form-data' + } + return config }, (error) => { diff --git a/style/src/components/FittingPage.js b/style/src/components/FittingPage.js deleted file mode 100644 index 5483cfc..0000000 --- a/style/src/components/FittingPage.js +++ /dev/null @@ -1,22 +0,0 @@ -import Navigation from './Navigationbar/Nav' -import Footer from './Footer' - -import React from 'react' -import { useSelector } from 'react-redux' - -function FittingPage() { - const user = useSelector((state) => state.auth.user) - - return ( -
- - {user && ( -
-

환영합니다, {user.email}님!

-
- )} - -
- ) -} -export default FittingPage diff --git a/style/src/components/FittingPage/ButtonBar.js b/style/src/components/FittingPage/ButtonBar.js new file mode 100644 index 0000000..527b3ef --- /dev/null +++ b/style/src/components/FittingPage/ButtonBar.js @@ -0,0 +1,229 @@ +import React, { useRef, useState } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { solid, regular } from '@fortawesome/fontawesome-svg-core/import.macro' + +import CardButton from './CardButton' +import authenticatedAxios from '../../api/authenticatedAxios' +import styles from './FittingPage.module.css' + +import saveImage from '../../images/save.png' +import uploadImage from '../../images/upload.png' +import changeBgImage from '../../images/landscape.png' +import resetImage from '../../images/reset.png' +import helpImage from '../../images/help.png' +import { API_URL } from '../../api/apiConfig' +import ClassMerger from '../common/ClassNameGenerater' + +function ButtonBar({ setErrorCode, showAlert, setIsDefaultPage }) { + // URL 입력 저장을 위한 state 생성 + const [inputUrl, setInputUrl] = useState('') + const [isModalOpen, setIsModalOpen] = useState(false) + const [clothes, setClothes] = useState([]) + const [selected, setSelected] = useState(-1) + + // 모달창 밖 클릭 시 모달 창 종료에 사용 + const uploadmodelBg = useRef() + const mouseDownEvent = useRef() + const mouseUpEvent = useRef() + const closeModalBtn = useRef() + + const handleInputUrl = (e) => { + setInputUrl(e.target.value) + } + + // URL 업로드 버튼 클릭 이벤트 핸들러 + const handleImgUploadClick = async () => { + if (selected < 0 || selected >= clothes.length) { + // 이미지 선택이 없거나 범위를 벗어난 경우 + return + } + + const file = await fetch(clothes[selected]) + .then((response) => response.blob()) + .then( + (blob) => + new File([blob], `clothes-${Date.now()}.png`, { type: 'image/png' }) + ) + + const user = JSON.parse(localStorage.getItem('user')) + if (!user || !user._id) { + console.error('User ID not found') + return + } + const userId = user._id + const urlWithoutQueryString = inputUrl.split('?')[0] + + const formData = new FormData() + formData.append('userId', userId) + formData.append('clothingImage', file) + formData.append('clothesUrl', urlWithoutQueryString) + for (let value of formData.values()) { + console.log(value) + } + try { + const response = await authenticatedAxios.post( + `${API_URL}/api/cloth-upload`, + formData + ) + if (response.status === 200) { + console.log('업로드에 성공했습니다!', response.data) + } else { + console.error('이미지 업로드에 실패했습니다.', response) + } + } catch (error) { + console.error('이미지 업로드 중 오류가 발생했습니다.', error) + } + } + + // url 업로드 버튼 : url에서 이미지 목록 가져옴 + const handleUrlUploadClick = async (event) => { + try { + const response = await authenticatedAxios.get( + `${API_URL}/cloth/api/clothesFromUrl`, + { params: { url: inputUrl } } + ) + + setErrorCode(response.data.code) + showAlert() + if (response.status === 200) { + setClothes(response.data.urls) + } + } catch (error) { + console.log(error.response.data.code) + setErrorCode(error.response.data.code) + showAlert() + } + } + + const handleModalOpen = () => { + setIsModalOpen(true) + } + + const handleModalClose = (event) => { + // close 버튼 누른 경우 창 닫기 + if ( + mouseDownEvent.current === closeModalBtn.current && + mouseUpEvent.current === closeModalBtn.current + ) { + setIsModalOpen(false) + } + // 모달창 바깥 클릭 시 창 닫기 + // 바깥 -(드래그)-> 안, 안 -(드래그)-> 밖도 꺼지지 않게 함 + // 위 경우도 꺼지게 하는 조건 (event.current === uploadmodelBg.current) + else if ( + mouseDownEvent.current === uploadmodelBg.current && + mouseUpEvent.current === uploadmodelBg.current + ) { + setIsModalOpen(false) + } + } + + // 불러온 이미지 하나 선택 + const clothesImageSelect = (index) => { + setSelected(index) + } + + return ( +
+ + {}} + /> + {}} + /> + setIsDefaultPage(true)} + /> + {}} /> + {isModalOpen && ( +
{ + mouseDownEvent.current = event.target + }} + onMouseUp={(event) => { + mouseUpEvent.current = event.target + }} + ref={uploadmodelBg} + > +
event.stopPropagation()} + > +

의류 올리기

+ ㅤ잘 나온 사진 하나를 선택해주세요! + + +
+ {clothes && + clothes.map((src, index) => ( + + ))} +
+
+ + +
+
+
+ )} +
+ ) +} + +function ClothesImageElement({ src, index, selected, onClick }) { + return ( +
onClick(index)} + > + + clothes +
+ ) +} + +export default ButtonBar diff --git a/style/src/components/FittingPage/CardButton.js b/style/src/components/FittingPage/CardButton.js new file mode 100644 index 0000000..094841f --- /dev/null +++ b/style/src/components/FittingPage/CardButton.js @@ -0,0 +1,14 @@ +import React from 'react' + +import styles from './FittingPage.module.css' + +function CardButton({ src, alt, text, onClick }) { + return ( + + ) +} + +export default CardButton diff --git a/style/src/components/FittingPage/FittingPage.js b/style/src/components/FittingPage/FittingPage.js new file mode 100644 index 0000000..4d3d189 --- /dev/null +++ b/style/src/components/FittingPage/FittingPage.js @@ -0,0 +1,66 @@ +import Navigation from '../Navigationbar/Nav' + +import LeftFitContainer from './LeftFitContainer' +import RightFitContainer from './RightFitContainer' + +import styles from './FittingPage.module.css' + +import React from 'react' +import { useState } from 'react' + +import shoppingImage from '../../images/shopping-mall.png' +import { Navigate } from 'react-router-dom' +import FittingPageAlert from './FittingPageAlert' + +function FittingPage() { + // const user = useSelector((state) => state.auth.user) + const [fittingImage, setFittingImage] = useState('') + const [isDefaultPage, setIsDefaultPage] = useState(true) + const [isShowAlert, setIsShowAlert] = useState(false) + const [errorCode, setErrorCode] = useState(null) + + const lsUser = JSON.parse(localStorage.getItem('user')) + const lsToken = localStorage.getItem('token') + + const showAlert = (time = 3000) => { + setIsShowAlert(true) + setTimeout(() => { + setIsShowAlert(false) + }, time) + } + + if (lsUser && lsToken) { + return ( + <> + +
+
+ +
+ +
+
+ + + ) + } else { + return + } +} +export default FittingPage diff --git a/style/src/components/FittingPage/FittingPage.module.css b/style/src/components/FittingPage/FittingPage.module.css new file mode 100644 index 0000000..29ed6d6 --- /dev/null +++ b/style/src/components/FittingPage/FittingPage.module.css @@ -0,0 +1,476 @@ +@font-face { + font-family: 'BMJUA'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/BMJUA.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@import url('https://cdn.rawgit.com/moonspam/NanumSquare/master/nanumsquare.css'); + +.mainContainer { + --nav-height: 95px; + --max-width: 1920px; + --max-height: 970px; + --leftContiner-width: 466px; + --rightContiner-width: calc(var(--max-width) - var(--leftContiner-width)); + /* --height : calc(100vh - 56px); */ + --height: calc(var(--max-height) - var(--nav-height)); + + + display: grid; + grid-template-columns: 4% 46% 4% 42% 4%; + height: var(--height); + /* overflow: hidden; */ +} + +.cardButton { + display: grid; + place-items: center; + text-align: center; + width: 140px; + height: 100px; + border-radius: 12px; + border-width: 0px; + background-color: #fbf7f2; + margin: 24px; +} + +.cardButton:hover { + background-color: #f1ede9; +} + +.cardButton .btnImage { + width: 60px; + height: 60px; +} + +.cardButton .btnText { + font-family: 'NanumSquare'; + color: #909090; + font-weight: 600; +} + +.buttonContainer { + display: flex; + justify-content: space-between; +} + +.imageContainer { + place-items: center; + text-align: center; + margin-top: 5%; +} + +.fittingImage { + height: 650px; + object-fit: cover; +} + +.rightFitContainer { + display: flex; + justify-content: center; + align-items: center; +} + +.closetContainer { + display: inline-block; + background-color: #eae7e1; + width: 775px; + height: 805px; + border: 1px solid black; + border-radius: 4%; + overflow-y: hidden; +} + +.closetNavbar { + height: 26%; + width: 100%; + background-color: grey; +} + +.searchBarContainer { + background-color: #f0f0f0; + width: 100%; + height: 30%; + display: flex; + justify-content: center; + padding-top: 2.2%; +} + +.searchBar { + display: inline-block; + width: 92%; + height: 40px; + border: 1.6px solid #b0b0b0; + border-radius: 20px; + display: flex; + align-items: center; +} + +.searchImage { + margin-left: 1.8%; +} + +.searchInput { + width: 91%; + margin-left: 1%; + background: transparent; + border: 0px; + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 16px; + font-weight: 600; +} + +.searchInput::placeholder { + color: #aaaaaa; +} + +.searchInput:focus { + outline: none; +} + +.mainMenuContainer { + background-color: #f0f0f0; + width: 100%; + height: 24%; + display: flex; + justify-content: center; + align-items: center; +} + +.mainMenu { + display: inline-block; + width: 92%; + /* border: 1px solid black; */ +} + +.mainMenuBtn { + height: 30px; + width: 80px; + border-radius: 15px; + border: 0px; + + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.mainMenuBtn:hover { + color: #ff7e36; + font-size: 18px; + font-weight: 600; + transition: all 0.3s ease-out; +} + +.mainMenuBtn_selected { + height: 30px; + width: 80px; + border-radius: 15px; + border: 0px; + background-color: #323232; + color: #ff7e36; + + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; + padding-top: 2px; +} + +.menuControlBtn { + background: transparent; + border: 0px; + font-size: 20px; + color: #505d6f; + margin: 0 1.5%; +} + +.subMenu1Container { + background-color: #eae7e1; + width: 100%; + height: 23%; + display: flex; + justify-content: center; + align-items: center; +} + +.subMenu1 { + display: inline-block; + width: 92%; +} + +.subMenu1Btn, +.subMenu1Btn_selected { + background: transparent; + border: 0px; + width: 72px; + position: relative; + + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.subMenu1Btn::after { + content: ''; + display: block; + position: absolute; + box-sizing: border-box; + bottom: -12px; + left: 16px; + width: 60%; + height: 5px; + transition: all 0.3s ease-out; +} + +.subMenu1Btn:hover::after, +.subMenu1Btn_selected::after { + content: ''; + display: block; + position: absolute; + box-sizing: border-box; + bottom: -10px; + left: 15px; + width: 60%; + height: 5px; + background-color: #ff7e36; +} + +.subMenu2Container { + background-color: #f0f0f0; + width: 100%; + height: 23%; + display: flex; + justify-content: space-between; + align-items: center; +} + +.subMenu2Btn { + background: transparent; + border: 0px; + width: 100px; + color: #aaaaaa; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.subMenu2Btn_selected { + background: transparent; + border: 0px; + width: 100px; + color: #ff7e36; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.subMenu2Btn:hover { + color: #ff7e36; + font-size: 18px; + font-weight: 600; + transition: all 0.3s ease-out; +} + +.filterBtn { + background: transparent; + border: 0px; + font-size: 18px; + margin-right: 1.6%; +} + +.closet { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + grid-auto-rows: 269px; + /* grid-gap: 20px; */ + overflow-y: auto; + max-height: 72%; + justify-content: space-between; + padding: 5px; +} + +.clothesElement { + width: 232px; + height: 254px; + display: inline-block; + border-radius: 6px; + background-color: #f6f6f6; + margin: 10px 10px; + padding: 12px; + /* border: 1px solid black; */ +} + +.clothesInner { + /* display: flex; */ + display: block; + text-align: center; +} + +.clothesImage { + display: block; + margin: 0 auto; + width: 160px; + height: 180px; +} + +.favoriteBtn { + background: transparent; + border: 0px; + + color: #aaaaaa; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.wearingBtn { + background: transparent; + border: 0px; + + color: #aaaaaa; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.removeBtn { + background: transparent; + border: 0px; + + color: #aaaaaa; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modalContent { + background-color: white; + padding: 24px; + width: 100%; + max-width: 800px; + height: 800px; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + position: relative; +} + +.modalContent h2 { + font-family: 'NanumSquare'; + font-weight: 600; + display: inline-block; +} + +.modalContent span { + font-family: 'NanumSquare'; + font-weight: 600; + display: inline-block; +} + +.modalContent input { + width: 100%; + padding: 8px; + display: block; + border: 1px solid #ccc; + border-radius: 4px; + margin-bottom: 16px; + + color: #aaaaaa; + font-family: 'NanumSquare'; + font-size: 16px; + font-weight: 600; +} + +.clothesImageSearchBtn { + background: transparent; + border: 0px; + position: absolute; + padding: 0; + top: 73px; + right: 40px; + font-size: 22px; +} + +.modalImageContainer { + width: 100%; + height: 80%; + border: 1px solid #cccccc; + border-radius: 4px; + overflow-y: auto; + grid-template-columns: repeat(5, 148px); + grid-auto-rows: 152px; + display: grid; + justify-content: space-evenly; +} + +.clothesImageBox { + position: relative; + display: inline-block; + border: 1px solid #cccccc; + border-radius: 4px; + margin: 1% 2%; +} + +.clothesImageBox img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.clothesChecked, +.clothesImageBox:hover { + filter: brightness(0.8); +} + +.imageCheckBtn { + position: absolute; + background: transparent; + top: 1%; + left: 3%; + border: 0px; + padding: 0px; +} + +.modalButtonContainer { + width: 100%; + height: 40px; + text-align: right; + margin-top: 12px; +} + +.modalContent .modalButtonContainer button { + background-color: #007bff; + border: none; + color: white; + padding: 8px 16px; + text-decoration: none; + display: inline-block; + font-size: 14px; + border-radius: 4px; + cursor: pointer; + margin-right: 8px; +} + +.modalContent button:last-child { + background-color: #ccc; + } + + .mainImage { + max-width: 100%; + height: auto; + } diff --git a/style/src/components/FittingPage/FittingPageAlert.js b/style/src/components/FittingPage/FittingPageAlert.js new file mode 100644 index 0000000..fce3294 --- /dev/null +++ b/style/src/components/FittingPage/FittingPageAlert.js @@ -0,0 +1,42 @@ +import AlertMessage from '../common/AlertMessage' + +function FittingPageAlert({ errorCode, isShowAlert, setIsShowAlert }) { + return ( + <> + {errorCode === 'FETCH_DONE' && ( + + )} + {errorCode === 'URL_ERROR_HTTP' && ( + + )} + {errorCode === 'URL_ERROR_INVALID_IMAGE' && ( + + )} + {errorCode === 'ERR_BAD_REQUEST' && ( + + )} + + ) +} + +export default FittingPageAlert diff --git a/style/src/components/FittingPage/LeftFitContainer.js b/style/src/components/FittingPage/LeftFitContainer.js new file mode 100644 index 0000000..a7c9ee1 --- /dev/null +++ b/style/src/components/FittingPage/LeftFitContainer.js @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from 'react' + +import CardButton from './CardButton' +import ButtonBar from './ButtonBar' + +import styles from './FittingPage.module.css' +import axios from 'axios' +import { API_URL } from '../../api/apiConfig' + +// import peopleTest from '../../images/people-test.png' + +function LeftFitContainer({ + setErrorCode, + showAlert, + fittingImage, + isDefaultPage, + setIsDefaultPage, +}) { + const [image, setImage] = useState('') + + useEffect(() => { + const fetchData = async () => { + try { + const user = JSON.parse(localStorage.getItem('user')) + if (!user || !user._id) { + console.error('User ID not found') + return + } + const userId = user._id + const response = await axios.get(`${API_URL}/userinfo/api/userimage`, { + params: { + userId: userId, + }, + }) + setImage(response.data.image) + } catch (error) { + console.error('Error fetching user data:', error) + } + } + fetchData() + }, []) + + const defaultImageUrl = image + + return ( +
+ +
+ userImage +
+
+ ) +} +export default LeftFitContainer diff --git a/style/src/components/FittingPage/RightFitContainer.js b/style/src/components/FittingPage/RightFitContainer.js new file mode 100644 index 0000000..db65fc2 --- /dev/null +++ b/style/src/components/FittingPage/RightFitContainer.js @@ -0,0 +1,334 @@ +import React, { memo } from 'react' +import { useEffect, useState } from 'react' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { solid, regular } from '@fortawesome/fontawesome-svg-core/import.macro' + +import styles from './FittingPage.module.css' +import { API_URL } from '../../api/apiConfig' + +import searchImage from '../../images/search.png' +import axios from 'axios' + +import LoadingIndicator from '../LoadingIndicator' + +function RightFitContainer({ setFittingImage, setIsDefaultPage }) { + const [mainMenu, setMainMenu] = useState('closet') + const [subMenu1, setSubMenu1] = useState('all') + const [subMenu2, setSubMenu2] = useState('all') + const [clothes, setClothes] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const list_subMenu1 = ['all', 'top', 'bottom'] + + //서버에서 clothes 이미지 데이터를 가져오는 함수 + const fetchClothesImages = async () => { + const user = JSON.parse(localStorage.getItem('user')) + if (!user || !user._id) { + console.error('User ID not found') + return + } + const userId = user._id + try { + const response = await axios.get(`${API_URL}/cloth/api/clothes`, { + params: { + userId: userId, + }, + }) + console.log(response) + setClothes( + response.data.map((imageItem) => ({ + id: imageItem._id, + src: imageItem.clothesImageLink, + })) + ) + } catch (err) { + console.error('Error fetching clothes images:', err) + } + } + useEffect(() => { + fetchClothesImages() + }, []) + + const toggleFavorite = (indexToToggle) => { + setClothes( + clothes.map((item, index) => + index === indexToToggle ? { ...item, favorite: !item.favorite } : item + ) + ) + } + + //피팅 + const handleFittingCloth = async (clothID) => { + const user = JSON.parse(localStorage.getItem('user')) + if (!user || !user._id) { + console.error('User ID not found') + return + } + const userId = user._id + try { + setIsLoading(true) + const response = await fetch('/api/fitting', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId, clothID }), + }) + + if (response.ok) { + const data = await response.json() + console.log(data.fittingImageLink) + + setFittingImage(data.fittingImageLink) + setIsDefaultPage(false) + setIsLoading(false) + } else { + console.error('Error loading fitting image') + } + } catch (error) { + console.error('Fitting Request Error:', error) + setIsLoading(false) + } + } + + //옷 제거 + const handleDeleteCloth = async (clothId) => { + const user = JSON.parse(localStorage.getItem('user')) + if (!user || !user._id) { + console.error('User ID not found') + return + } + + const confirmation = window.confirm('옷을 삭제하시겠습니까?') + if (!confirmation) { + return + } + console.log(clothId) + try { + const response = await axios.delete( + `${API_URL}/cloth/api/delete/${clothId}` + ) + fetchClothesImages() + } catch (error) { + console.error('Error deleting cloth:', error) + } + } + + return ( +
+
+
+
+
+ search button + +
+
+
+
+ + + +
+
+
+ +
+ + + +
+ +
+
+
+ + +
+ +
+
+
+ {isLoading && } + {clothes.map((item, index) => ( + + ))} +
+
+
+ ) +} +export default RightFitContainer + +function ClothesElement({ + id, + index, + src, + favorite, + onHeartClick, + consolea, + handleFittingCloth, + handleDeleteCloth, +}) { + return ( +
+
+ clothes + + + +
+
+ ) +} + +const MemoClothesElement = memo(ClothesElement) diff --git a/style/src/components/Footer.js b/style/src/components/Footer.js index b349480..d1dea65 100644 --- a/style/src/components/Footer.js +++ b/style/src/components/Footer.js @@ -12,8 +12,8 @@ function Footer() {
- 이용 약관 - 개인정보처리방침 + 이용 약관 + 개인정보처리방침
diff --git a/style/src/components/LoadingIndicator.css b/style/src/components/LoadingIndicator.css new file mode 100644 index 0000000..5af176f --- /dev/null +++ b/style/src/components/LoadingIndicator.css @@ -0,0 +1,50 @@ +.loading-indicator-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + background-color: rgba(255,255,255,0.8); +} + +.loading-indicator { + display: flex; + justify-content: space-around; + align-items: center; + width: 70px; + height: 35px; +} + +.circle { + width: 15px; + height: 15px; + border-radius: 50%; + background-color: #333; + animation: bounce 2s infinite ease-in-out; +} + +.circle:nth-child(1) { + animation-delay: .2s; +} + +.circle:nth-child(2) { + animation-delay: .4s; +} + +.circle:nth-child(3) { + animation-delay: .6s; +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-15px); + } +} + diff --git a/style/src/components/LoadingIndicator.js b/style/src/components/LoadingIndicator.js new file mode 100644 index 0000000..d75db05 --- /dev/null +++ b/style/src/components/LoadingIndicator.js @@ -0,0 +1,16 @@ +import React from 'react' +import './LoadingIndicator.css' + +const LoadingIndicator = () => { + return ( +
+
+
+
+
+
+
+ ) +} + +export default LoadingIndicator diff --git a/style/src/components/MyPage/FormBox.js b/style/src/components/MyPage/FormBox.js new file mode 100644 index 0000000..ce86df3 --- /dev/null +++ b/style/src/components/MyPage/FormBox.js @@ -0,0 +1,58 @@ +import ClassMerger from '../common/ClassNameGenerater' +// import Proptypes from 'prop-types' +import { Form } from 'react-bootstrap' +import styles from './MyPage.module.css' + +function FormBox({ + id, + label, + type, + min, + max, + pattern, + setState, + value, + isInvalid, + invalidTest, + disabled = false, +}) { + return ( + + + {label} + + { + const input = event.target.value + setState(input) + }} + /> + + {invalidTest} + + + ) +} + +// FormBox.propTypes = { +// id: Proptypes.string.isRequired, +// label: Proptypes.string.isRequired, +// type: Proptypes.string.isRequired, +// min: Proptypes.string, +// max: Proptypes.string, +// pattern: Proptypes.string, +// setState: Proptypes.func, +// disabled: Proptypes.bool, +// } + +export default FormBox diff --git a/style/src/components/MyPage/LeftSubMyPage.js b/style/src/components/MyPage/LeftSubMyPage.js new file mode 100644 index 0000000..c0dc4c1 --- /dev/null +++ b/style/src/components/MyPage/LeftSubMyPage.js @@ -0,0 +1,70 @@ +import styles from './MyPage.module.css' +import profileImage from '../../images/profile.jpg' +import Proptypes from 'prop-types' +import ClassMerger from '../common/ClassNameGenerater' + +// 선택된 버튼에 주황색 넣어주는 함수 +function TransButton({ context, option, page, onClick }) { + return ( + + ) +} + +TransButton.propTypes = { + context: Proptypes.string.isRequired, + option: Proptypes.string.isRequired, + onClick: Proptypes.func.isRequired, +} + +function LeftSubMyPage({ page, onClickHandler }) { + return ( +
+

마이페이지

+ profile + + + {/* + */} +
+ ) +} + +LeftSubMyPage.propTypes = { + page: Proptypes.string.isRequired, + onClickHandler: Proptypes.func.isRequired, +} + +export default LeftSubMyPage diff --git a/style/src/components/MyPage/MyPage.js b/style/src/components/MyPage/MyPage.js new file mode 100644 index 0000000..3d8bae6 --- /dev/null +++ b/style/src/components/MyPage/MyPage.js @@ -0,0 +1,52 @@ +import { useState } from 'react' +import styles from './MyPage.module.css' +import LeftSubMyPage from './LeftSubMyPage' +import RightSubMyPage from './RightSubMyPage' +import Navigation from '../Navigationbar/Nav.js' +import { Navigate } from 'react-router-dom' +import MyPageAlert from './MyPageAlert' + +function MyPage({ currPage = 'privacy' }) { + const [page, setPage] = useState(currPage) + const [isShowAlert, setIsShowAlert] = useState(false) + const [errorCode, setErrorCode] = useState(false) + + const showAlert = (time = 3000) => { + setIsShowAlert(true) + setTimeout(() => { + setIsShowAlert(false) + }, time) + } + + const changePage = (pageName) => { + setPage(pageName) + } + + const lsUser = JSON.parse(localStorage.getItem('user')) + const lsToken = localStorage.getItem('token') + + if (lsUser && lsToken) { + return ( + <> + +
+ + +
+ + + ) + } else { + return + } +} + +export default MyPage diff --git a/style/src/components/MyPage/MyPage.module.css b/style/src/components/MyPage/MyPage.module.css new file mode 100644 index 0000000..5486461 --- /dev/null +++ b/style/src/components/MyPage/MyPage.module.css @@ -0,0 +1,195 @@ +@font-face { + font-family: 'BMJUA'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/BMJUA.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@import url('https://cdn.rawgit.com/moonspam/NanumSquare/master/nanumsquare.css'); + +.myPageRoot { + position: relative; +} + +.mainContainer { + --nav-height : 95px; + /* --max-width : 1920px; */ + --max-width : 100vw; + --max-height : 100vh; + --leftContiner-width : 466px; + --rightContiner-width : calc( var(--max-width) - var(--leftContiner-width)); + /* --height : calc(100vh - 56px); */ + --height : calc(var(--max-height) - var(--nav-height)); + + + display: grid; + grid-template-columns: var(--leftContiner-width) var(--rightContiner-width); + height: var(--height); + /* overflow: hidden; */ +} + +.basicFont { + color: #8f8f8f; + font-family: 'NanumSquare'; + font-weight: 600; + font-size: 20px; +} + +.subLeftContiner { + --title-width : 322px; + + height: 100%; + background-color: #fbf7f2; + display: flex; + flex-direction: column; + align-items: center; +} + +.title { + font-family: 'BMJUA'; + color: #ff7f3f; + width: var(--title-width); + margin-top: 44px; + margin-bottom: 44px; + font-size: 48px; +} + +.profileImage { + width: var(--title-width); + height: var(--title-width); + border-radius: 50%; + margin-bottom: 18px; +} + +.transparentButton { + background: transparent; + border: none; + margin-top: 28px; + padding: 0px; + transition: color 0.2s ease-out; +} + +.transparentButton:hover { + color: #ff7f3f;; +} + +.selected { + color: #ff7f3f; +} + +.subRightContiner { + --rightContainer-padding-left : 80px; + --rightContainer-padding-top : 64px; + --rightContainer-column-width : calc((var(--rightContiner-width) - var(--rightContainer-padding-left)) / 2); + + display: grid; + /* grid-template-columns: var(--rightContainer-column-width) var(--rightContainer-column-width); */ + grid-template-columns: 45% 45%; + grid-template-rows: repeat(4, minmax(160px, 160px)); + overflow: hidden; + padding-top: var(--rightContainer-padding-top); + padding-left: var(--rightContainer-padding-left); +} + +.alertContainer { + position: absolute; + +} + +.formGroup { + padding-right: var(--rightContainer-padding-left); +} + +.formLabel { + margin: 0px 0px 8px 0px; + padding: 0px; +} + +.formControl { + height: 50px; + border-color: #d7d7d7; + border-width: 2px; + border-radius: 14px; + + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; +} + +.genderFormContainer { + padding-right: var(--rightContainer-padding-left); +} + +.maleButton { + /* width: 250px; */ + width: 46%; + + height: 46px; + border: 2px solid #d7d7d7; + border-radius: 14px; + background-color: white; + /* margin-right: 18px; */ + margin-right: 4%; + + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; + transition: all 0.2s ease-out; +} + +.maleSelected, +.maleButton:hover { + background-color: #c0dfff; + color: black; +} + +.femaleButton { + /* width: 250px; */ + width: 46%; + height: 46px; + border: 2px solid #d7d7d7; + border-radius: 14px; + background-color: white; + /* margin-left: 18px; */ + margin-left: 4%; + + + color: #6d6d6d; + font-family: 'NanumSquare'; + font-size: 18px; + font-weight: 600; + transition: all 0.2s ease-out; +} + +.femaleSelected, +.femaleButton:hover { + background-color: #fca8a5; + color: black; +} + +.submitWarpper { + text-align: right; + padding-right: var(--rightContainer-padding-left); +} + +.submitButton { + height: 60px; + width: 128px; + color: #606060; + font-weight: 800; + background-color: #ffa978; + background-color: cutom-color; + border-radius: 14px; + border-width: 0px; + transition: all 0.2s ease-out; +} + +.submitButton:hover { + background-color: #ff9878; +} + +.submitButton:active { + background-color: #ff8f4f; +} \ No newline at end of file diff --git a/style/src/components/MyPage/MyPageAlert.js b/style/src/components/MyPage/MyPageAlert.js new file mode 100644 index 0000000..be3903a --- /dev/null +++ b/style/src/components/MyPage/MyPageAlert.js @@ -0,0 +1,35 @@ +import AlertMessage from '../common/AlertMessage' + +function MyPageAlert({ errorCode, isShowAlert, setIsShowAlert }) { + const errorCodeList = ['UPDATE_DONE', 'DuplicateKey'] + return ( + <> + {errorCode === 'UPDATE_DONE' && ( + + )} + {errorCode === 'DuplicateKey' && ( + + )} + {!errorCodeList.includes(errorCode) && ( + + )} + + ) +} + +export default MyPageAlert diff --git a/style/src/components/MyPage/MyPrivacy.js b/style/src/components/MyPage/MyPrivacy.js new file mode 100644 index 0000000..78ae74c --- /dev/null +++ b/style/src/components/MyPage/MyPrivacy.js @@ -0,0 +1,175 @@ +import ClassMerger from '../common/ClassNameGenerater' +import styles from './MyPage.module.css' +import FormBox from './FormBox' +import { useEffect, useRef, useState } from 'react' +import authenticatedAxios from '../../api/authenticatedAxios' +import { API_URL } from '../../api/apiConfig' +import { updateUser } from '../User/UpdateInfo.js' + +function MyPrivacy({ userId, showAlert, setErrorCode }) { + const [name, setName] = useState('') + const [gender, setGender] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + + const user = useRef({}) + + const [isNameInvalid, setIsNameInvalid] = useState(false) + const [isEmailInvalid, setIsEmailInvalid] = useState(false) + const [isPhoneInvalid, setIsPhoneInvalid] = useState(false) + + const maleButtonHandler = (event) => { + if (gender !== 'male') { + setGender('male') + } + } + const femaleButtonHandler = (event) => { + if (gender !== 'female') { + setGender('female') + } + } + + // 입력갑 검사 및 제출 + const onSubmit = async (event) => { + event.preventDefault() + + let modifyUser = {} + + if (/^[가-힣a-zA-Z\s]+$/.test(name)) { + setIsNameInvalid(false) + // user.current.name = name + modifyUser.name = name + } else { + setIsNameInvalid(true) + } + if ( + /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i.test( + email + ) + ) { + setIsEmailInvalid(false) + // user.current.email = email + modifyUser.email = email + } else { + setIsEmailInvalid(true) + } + if (/^\d{3}-\d{4}-\d{4}$/.test(phone)) { + setIsPhoneInvalid(false) + // user.current.phoneNumber = phone + modifyUser.phoneNumber = phone + } else { + setIsPhoneInvalid(true) + } + if (!isNameInvalid && !isEmailInvalid && !isPhoneInvalid) { + modifyUser.userId = userId + modifyUser.gender = gender + updateUser(modifyUser).then((result) => { + setErrorCode(result.code) + showAlert() + }) + } + } + + // 신체 정보 fetch + useEffect(() => { + const fetchSize = async () => { + try { + const response = await authenticatedAxios.get( + `${API_URL}/userInfo/api/info`, + { params: { userId: userId } } + ) + if (response.status === 200 || response.status === 201) { + user.current = response.data.user + setName(response.data.user.name) + setEmail(response.data.user.email) + setGender(response.data.user.gender) + setPhone(response.data.user.phoneNumber) + + return { success: true, result: response.data } + } else { + return { success: false, error: 'Size Fetch Failed.' } + } + } catch (error) { + if (error.response && error.response.status >= 500) { + return { success: false, error: 'Server error occurred.' } + } else { + return { success: false, error: error.message } + } + } + } + if (userId) fetchSize() + }, [userId]) + return ( +
+
+ +
+

+ 성별 +

+ + +
+ + +
+
+
+
+ +
+
+
+ ) +} + +export default MyPrivacy diff --git a/style/src/components/MyPage/MySize.js b/style/src/components/MyPage/MySize.js new file mode 100644 index 0000000..b7637d4 --- /dev/null +++ b/style/src/components/MyPage/MySize.js @@ -0,0 +1,178 @@ +import ClassMerger from '../common/ClassNameGenerater' +import styles from './MyPage.module.css' +import FormBox from './SizeFormBox' +import { useState, useEffect } from 'react' + +import authenticatedAxios from '../../api/authenticatedAxios' +import { API_URL } from '../../api/apiConfig' +import { updateSize } from '../User/UpdateInfo' + +function MySize({ userId, showAlert, setErrorCode }) { + const [height, setHeight] = useState(0) + const [weight, setWeight] = useState(0) + const [shoulderWidth, SetshoulderWidth] = useState(0) + const [chestWidth, setChestWidth] = useState(0) + const [length, setLength] = useState(0) + // 저장한 후 sizeAPI로 변경된 데이터 다시 받아오게 함 + const [reFetchTrigger, setReFetchTrigger] = useState(0) + + const [isHeightInvalid, setIsHeightInvalid] = useState(0) + const [isWeightInvalid, setIsWeightInvalid] = useState(0) + + const onSubmit = async (event) => { + event.preventDefault() + + let modifySize = {} + // 키 체크 + if (height) { + setIsHeightInvalid(false) + modifySize.height = height + } else { + setIsHeightInvalid(true) + } + // 몸무게 체크 + if (weight) { + setIsWeightInvalid(false) + modifySize.weight = weight + } else { + setIsWeightInvalid(true) + } + // 어깨너비 + if (shoulderWidth) { + modifySize.shoulderWidth = shoulderWidth + } + // 가슴단면 + if (chestWidth) { + modifySize.chestWidth = chestWidth + } + // 총장 + if (length) { + modifySize.length = length + } + modifySize.userId = userId + updateSize(modifySize).then((result) => { + setErrorCode(result.code) + showAlert() + setReFetchTrigger((curr) => curr + 1) + }) + } + + // 신체 정보 fetch + // DB 제대로 만들어지면 어떻게 될 지 몰라서 일단 2번 fetch하게 만듦 + useEffect(() => { + const fetchSize1 = async () => { + try { + const response = await authenticatedAxios.get( + `${API_URL}/userInfo/api/size`, + { params: { userId: userId } } + ) + if (response.status === 200 || response.status === 201) { + setLength(response.data.message.length) + SetshoulderWidth(response.data.message.shoulderWidth) + setChestWidth(response.data.message.chestWidth) + return { success: true, result: response.data } + } else { + return { success: false, error: 'Size Fetch Failed.' } + } + } catch (error) { + if (error.response && error.response.status >= 500) { + return { success: false, error: 'Server error occurred.' } + } else { + return { success: false, error: error.message } + } + } + } + const fetchSize2 = async () => { + try { + const response = await authenticatedAxios.get( + `${API_URL}/userInfo/api/info`, + { params: { userId: userId } } + ) + if (response.status === 200 || response.status === 201) { + setHeight(response.data.user.height) + setWeight(response.data.user.weight) + return { success: true, result: response.data } + } else { + return { success: false, error: 'Size Fetch Failed.' } + } + } catch (error) { + if (error.response && error.response.status >= 500) { + return { success: false, error: 'Server error occurred.' } + } else { + return { success: false, error: error.message } + } + } + } + if (userId) { + fetchSize1() + fetchSize2() + } + console.log('fetch') + }, [userId, reFetchTrigger]) + + return ( +
+
+ + + + + +
+
+
+ +
+
+
+ ) +} + +export default MySize diff --git a/style/src/components/MyPage/RightSubMyPage.js b/style/src/components/MyPage/RightSubMyPage.js new file mode 100644 index 0000000..93a512d --- /dev/null +++ b/style/src/components/MyPage/RightSubMyPage.js @@ -0,0 +1,45 @@ +import Proptypes from 'prop-types' +import MyPrivacy from './MyPrivacy' +import MySize from './MySize' +import { useSelector } from 'react-redux' + +function RightSubMyPage({ page, showAlert, setErrorCode }) { + // 새로고침 or 제출 버튼 누르면 웹 페이지 리프레쉬 되면서 redux 초기화 됨 + // redux 초기화 되면서 user에 저장된 데이터 없어져서 오류 발생. + const user = useSelector((state) => state.auth.user) + + switch (page) { + case 'privacy': + return ( + + ) + case 'size': + return ( + + ) + // case 'closet': + // return ( + // <>옷장 - 어떻게 해야할 지 생각 중. 무슨 데이터가 들어가야 할까요? + // ) + // case 'style': + // return ( + // <>스타일 - 어떻게 해야할 지 생각 중. 무슨 데이터가 들어가야 할까요? + // ) + default: + return <>error! + } +} + +RightSubMyPage.propTypes = { + page: Proptypes.string.isRequired, +} + +export default RightSubMyPage diff --git a/style/src/components/MyPage/SizeFormBox.js b/style/src/components/MyPage/SizeFormBox.js new file mode 100644 index 0000000..ca25f42 --- /dev/null +++ b/style/src/components/MyPage/SizeFormBox.js @@ -0,0 +1,43 @@ +import ClassMerger from '../common/ClassNameGenerater' +import { Form } from 'react-bootstrap' +import styles from './MyPage.module.css' + +function FormBox({ + id, + label, + value, + min, + max, + setState, + placeholder, + isInvalid = false, + invalidTest, +}) { + return ( + + + {label} + + { + const input = event.target.value + const isValid = /^\d+(\.\d{0,1})?$/.test(input) || input === '' + if (isValid && min <= input && input < max) { + const floatInput = parseFloat(input) + setState(isNaN(floatInput) ? 0 : floatInput) + } + }} + /> + + {invalidTest} + + + ) +} + +export default FormBox diff --git a/style/src/components/MyPage/getUserInfo.js b/style/src/components/MyPage/getUserInfo.js new file mode 100644 index 0000000..e69de29 diff --git a/style/src/components/Navigationbar/Nav.js b/style/src/components/Navigationbar/Nav.js index 64ee4cc..220e872 100644 --- a/style/src/components/Navigationbar/Nav.js +++ b/style/src/components/Navigationbar/Nav.js @@ -52,13 +52,13 @@ function Navigation() { 피팅 - 코디 추천 - + */} - -
-
- ) -} -export default RecommendPage diff --git a/style/src/components/RecommendPage/RecommendPage.css b/style/src/components/RecommendPage/RecommendPage.css new file mode 100644 index 0000000..e421747 --- /dev/null +++ b/style/src/components/RecommendPage/RecommendPage.css @@ -0,0 +1,36 @@ +.recommend-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: calc(100vh - 100px); + background-color: #f8f9fa; + } + + h2 { + font-size: 2rem; + color: #333; + margin-top: 2rem; + margin-bottom: 0.5rem; + text-align:center; + } + + p { + font-size: 1.25rem; + color: #666; + text-align:center; + } + + h3 { + font-size: 1rem; + color: #999; + margin-top: 1rem; + font-style: italic; + text-align:center; + } + + .coming-soon-image { + width: 100%; + max-width: 400px; + margin-top: 2rem; + } \ No newline at end of file diff --git a/style/src/components/RecommendPage/RecommendPage.js b/style/src/components/RecommendPage/RecommendPage.js new file mode 100644 index 0000000..5d78ffb --- /dev/null +++ b/style/src/components/RecommendPage/RecommendPage.js @@ -0,0 +1,26 @@ +import React from 'react' +import Navigation from '../Navigationbar/Nav' +import Footer from '../Footer' +import './RecommendPage.css' +import comingSoonImage from '../../images/commingsoon.png' + +function RecommendPage() { + return ( +
+ +
+

추천 시스템이 곧 도입됩니다.

+

최고의 제품을 추천 받아보세요.

+

여러분께 딱 맞는 아이템을 찾아드립니다.

+

COMING SOON...

+ Coming Soon +
+
+
+ ) +} +export default RecommendPage diff --git a/style/src/components/User/Login.js b/style/src/components/User/Login.js index 41a4a03..0c9be33 100644 --- a/style/src/components/User/Login.js +++ b/style/src/components/User/Login.js @@ -28,8 +28,8 @@ function LoginPage({ onClose }) { password, }) if (response.status === 200 || response.status === 201) { - localStorage.setItem('token', response.data.token) - dispatch(login(response.data.user)) + localStorage.setItem('user', JSON.stringify(response.data.user)) + dispatch(login(response.data)) onClose() } } catch (error) { diff --git a/style/src/components/User/Register.js b/style/src/components/User/Register.js index 7042e55..08c1bcf 100644 --- a/style/src/components/User/Register.js +++ b/style/src/components/User/Register.js @@ -14,6 +14,8 @@ async function registerUser(userData) { if (userData.file) { formData.append('file', userData.file) + } else { + return { success: false, error: '이미지를 업로드 해주세요.' } } try { @@ -27,16 +29,22 @@ async function registerUser(userData) { } ) - if (response.status === 200 || response.status === 201) { + if (response.status >= 200 && response.status < 300) { return { success: true, result: response.data } } else { - return { success: false, error: 'Registration failed.' } + const errorData = await response.json() + return { + success: false, + error: errorData.error || 'Registration failed.', + } } } catch (error) { - if (error.response && error.response.status >= 500) { - return { success: false, error: 'Server error occurred.' } + if (error.response) { + return { success: false, error: error.response.data.error } + } else if (error.request) { + return { success: false, error: 'Network error occurred.' } } else { - return { success: false, error: error.message } + return { success: false, error: 'An unknown error occurred.' } } } } diff --git a/style/src/components/User/SignupPage.js b/style/src/components/User/SignupPage.js index 645d49b..77e9c21 100644 --- a/style/src/components/User/SignupPage.js +++ b/style/src/components/User/SignupPage.js @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react' +import React, { useState, useRef, useEffect } from 'react' import { Tooltip } from 'react-tooltip' import { Modal, Button } from 'react-bootstrap' import ImageUploader from './ImageUploader' @@ -15,6 +15,8 @@ import { validateInteger, } from './Validations' +import LoadingIndicator from '../LoadingIndicator' + function SignupPage({ onClose }) { const dispatch = useDispatch() const [name, setName] = useState('') @@ -63,6 +65,10 @@ function SignupPage({ onClose }) { }, 500) } + // 이메일 중복 검사 + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const handleNameChange = (e) => { setName(e.target.value) onBlurHandler(e, validateName, setNameError) @@ -185,6 +191,7 @@ function SignupPage({ onClose }) { if (canSubmitted) { try { + setLoading(true) const userData = { email, name, @@ -204,10 +211,15 @@ function SignupPage({ onClose }) { localStorage.setItem('token', token) dispatch(login({ token, user })) onClose() + } else { + setErrorMessage(registerResult.error) + alert(registerResult.error) } } catch (error) { console.log('registerfail') handleInvalidForm() + } finally { + setLoading(false) } } else { console.log('submitfail') @@ -217,283 +229,293 @@ function SignupPage({ onClose }) { } return ( - - - 회원가입 - - -
-
- - onBlurHandler(e, validateName, setNameError)} - required - /> - {nameError &&
{nameError}
} -
-
- - - onBlurHandler(e, validatePhoneNumber, setPhoneError) - } - required - /> - {phoneError &&
{phoneError}
} -
-
- - onBlurHandler(e, validateEmail, setEmailError)} - required - /> -
- {emailError &&
{emailError}
} -
- - - onBlurHandler(e, validatePassword, setPasswordError) - } - required - /> - {passwordError && ( -
{passwordError}
- )} -
-
- - - onBlurHandler( - e, - (value, setError) => - validateConfirmPassword(value, password, setError), - setConfirmPasswordError - ) - } - required - /> - {confirmPasswordError && ( -
{confirmPasswordError}
- )} -
-
- -
-
- - -
-
-
-
- -
- + + + 회원가입 + + + +
+ + onBlurHandler(e, validateName, setNameError)} + required /> -
유의사항(보기)
- -

* 이미지 업로드 가이드라인

-

- (1) 사용자 전신 사진 촬영 시, -
사용자의 무릎 위 부터 머리 끝까지 -
- 화면에 꽉 찰 수 있도록 촬영해주세요. -

-

(2) 상의, 하의 색이 다르게 해주세요.

-

(3) 한 장만 업로드 해주세요.

-
+ {nameError &&
{nameError}
}
-
-
- -
- +
+ - onBlurHandler(e, validateInteger, setHeightError) + onBlurHandler(e, validatePhoneNumber, setPhoneError) } required /> - {heightError && ( -
{heightError}
- )} + {phoneError &&
{phoneError}
}
-
- +
+ onBlurHandler(e, validateEmail, setEmailError)} + required + /> +
+ {emailError &&
{emailError}
} +
+ + - onBlurHandler(e, validateInteger, setWeightError) + onBlurHandler(e, validatePassword, setPasswordError) } required /> - {weightError && ( -
{weightError}
+ {passwordError && ( +
{passwordError}
)}
-
-
-
- +
+ + onBlurHandler( + e, + (value, setError) => + validateConfirmPassword(value, password, setError), + setConfirmPasswordError + ) + } + required /> -
- - {favoriteStyle.style || '스타일 선택'} - - -
- {styleListOpen && ( -
    -
  • handleStyleItemClick('스포츠')}>스포츠
  • -
  • handleStyleItemClick('캐주얼')}>캐주얼
  • -
  • handleStyleItemClick('클래식')}>클래식
  • -
+ {confirmPasswordError && ( +
{confirmPasswordError}
)}
-
-
-
- - -
- - {favoriteStyle.color || '색상 선택'} - - +
+ +
+
+ + +
- {colorListOpen && ( -
    -
  • handleColorItemClick('검정')}>검정
  • -
  • handleColorItemClick('파랑')}>파랑
  • -
  • handleColorItemClick('빨강')}>빨강
  • -
  • handleColorItemClick('노랑')}>노랑
  • -
  • handleColorItemClick('하양')}>하양
  • -
  • handleColorItemClick('초록')}>초록
  • -
- )}
-
-
-
- - -
- - {favoriteStyle.fit || '핏 선택'} - - +
+ +
+ +
유의사항(보기)
+ +

* 이미지 업로드 가이드라인

+

+ (1) 사용자 전신 사진 촬영 시, +
사용자의 무릎 위 부터 머리 끝까지 +
+ 화면에 꽉 찰 수 있도록 촬영해주세요. +

+

(2) 상의, 하의 색이 다르게 해주세요.

+

(3) 한 장만 업로드 해주세요.

+
+
+
+
+ +
+ + + onBlurHandler(e, validateInteger, setHeightError) + } + required + /> + {heightError && ( +
{heightError}
+ )} +
+
+ + + onBlurHandler(e, validateInteger, setWeightError) + } + required + /> + {weightError && ( +
{weightError}
+ )} +
+
+
+
+ + +
+ + {favoriteStyle.style || '스타일 선택'} + + +
+ {styleListOpen && ( +
    +
  • handleStyleItemClick('스포츠')}> + 스포츠 +
  • +
  • handleStyleItemClick('캐주얼')}> + 캐주얼 +
  • +
  • handleStyleItemClick('클래식')}> + 클래식 +
  • +
+ )} +
+
+
+
+ + +
+ + {favoriteStyle.color || '색상 선택'} + + +
+ {colorListOpen && ( +
    +
  • handleColorItemClick('검정')}>검정
  • +
  • handleColorItemClick('파랑')}>파랑
  • +
  • handleColorItemClick('빨강')}>빨강
  • +
  • handleColorItemClick('노랑')}>노랑
  • +
  • handleColorItemClick('하양')}>하양
  • +
  • handleColorItemClick('초록')}>초록
  • +
+ )} +
+
+
+
+ + +
+ + {favoriteStyle.fit || '핏 선택'} + + +
+ {fitListOpen && ( +
    +
  • handleFitItemClick('정핏')}>정핏
  • +
  • handleFitItemClick('슬림핏')}>슬림핏
  • +
  • handleFitItemClick('오버핏')}>오버핏
  • +
+ )}
- {fitListOpen && ( -
    -
  • handleFitItemClick('정핏')}>정핏
  • -
  • handleFitItemClick('슬림핏')}>슬림핏
  • -
  • handleFitItemClick('오버핏')}>오버핏
  • -
- )}
-
-
- - - - - - - +
+ + + + {loading && } + + + + + ) } diff --git a/style/src/components/User/UpdateInfo.js b/style/src/components/User/UpdateInfo.js new file mode 100644 index 0000000..cb704b3 --- /dev/null +++ b/style/src/components/User/UpdateInfo.js @@ -0,0 +1,40 @@ +import authenticatedAxios from '../../api/authenticatedAxios' +import { API_URL } from '../../api/apiConfig' + +export async function updateUser(userData) { + try { + const response = await authenticatedAxios.put( + `${API_URL}/userInfo/api/privacy`, + userData + ) + if (response.status === 200 || response.status === 201) { + return response.data + } + } catch (error) { + if (error.response && error.response.status >= 500) { + return error.response.data + } else { + return error.response.data + } + } +} + +export async function updateSize(userData) { + try { + const response = await authenticatedAxios.put( + `${API_URL}/userInfo/api/size`, + userData + ) + console.log('in updatesize ') + + if (response.status === 200 || response.status === 201) { + return response.data + } + } catch (error) { + if (error.response && error.response.status >= 500) { + return error.response.data + } else { + return error + } + } +} diff --git a/style/src/components/User/Validations.js b/style/src/components/User/Validations.js index fd4b62f..ef50704 100644 --- a/style/src/components/User/Validations.js +++ b/style/src/components/User/Validations.js @@ -59,11 +59,12 @@ export const validateConfirmPassword = ( } export const validateInteger = (number, setNumberError) => { - if (Number.isInteger(Number(number))) { + const regex = new RegExp(/^\d+(\.\d)?$/) + if (regex.test(number) && number !== '') { setNumberError('') return true } else { - setNumberError('정수만 입력 가능합니다.') + setNumberError('정수 또는 소수점 한 자리까지 입력 가능합니다.') return false } } diff --git a/style/src/components/common/AlertMessage.js b/style/src/components/common/AlertMessage.js new file mode 100644 index 0000000..240dd86 --- /dev/null +++ b/style/src/components/common/AlertMessage.js @@ -0,0 +1,28 @@ +import Alert from 'react-bootstrap/Alert' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { solid } from '@fortawesome/fontawesome-svg-core/import.macro' +import styles from './AlertMessage.module.css' + +function AlertMessage({ variant, message, show, setShow }) { + return ( + setShow(false)} + dismissible + style={{ position: 'absolute' }} + > + {variant === 'success' && ( + + )} + {variant === 'danger' && ( + + )} + {' '} + {message} + + ) +} + +export default AlertMessage diff --git a/style/src/components/common/AlertMessage.module.css b/style/src/components/common/AlertMessage.module.css new file mode 100644 index 0000000..46335e5 --- /dev/null +++ b/style/src/components/common/AlertMessage.module.css @@ -0,0 +1,6 @@ +.alertMessageBox { + top: 10%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 9999; +} \ No newline at end of file diff --git a/style/src/components/common/ClassNameGenerater.js b/style/src/components/common/ClassNameGenerater.js new file mode 100644 index 0000000..ba8cb5d --- /dev/null +++ b/style/src/components/common/ClassNameGenerater.js @@ -0,0 +1,5 @@ +function ClassMerger(classNames) { + return classNames.join(' ') +} + +export default ClassMerger diff --git a/style/src/features/authSlices.js b/style/src/features/authSlices.js index 276ca62..45f7ea7 100644 --- a/style/src/features/authSlices.js +++ b/style/src/features/authSlices.js @@ -2,7 +2,8 @@ import { createSlice } from '@reduxjs/toolkit' const initialState = { isAuthenticated: false, - user: null, + // user: null, + user: localStorage.getItem('user'), token: localStorage.getItem('token'), } diff --git a/style/src/images/commingsoon.png b/style/src/images/commingsoon.png new file mode 100644 index 0000000..2180098 Binary files /dev/null and b/style/src/images/commingsoon.png differ diff --git a/style/src/images/help.png b/style/src/images/help.png new file mode 100644 index 0000000..1190c3e Binary files /dev/null and b/style/src/images/help.png differ diff --git a/style/src/images/landscape.png b/style/src/images/landscape.png new file mode 100644 index 0000000..c219690 Binary files /dev/null and b/style/src/images/landscape.png differ diff --git a/style/src/images/people-test.png b/style/src/images/people-test.png new file mode 100644 index 0000000..d0d1c5d Binary files /dev/null and b/style/src/images/people-test.png differ diff --git a/style/src/images/profile.jpg b/style/src/images/profile.jpg new file mode 100644 index 0000000..f7ac204 Binary files /dev/null and b/style/src/images/profile.jpg differ diff --git a/style/src/images/reset.png b/style/src/images/reset.png new file mode 100644 index 0000000..09c9cc8 Binary files /dev/null and b/style/src/images/reset.png differ diff --git a/style/src/images/save.png b/style/src/images/save.png new file mode 100644 index 0000000..efeffb2 Binary files /dev/null and b/style/src/images/save.png differ diff --git a/style/src/images/search.png b/style/src/images/search.png new file mode 100644 index 0000000..6958e63 Binary files /dev/null and b/style/src/images/search.png differ diff --git a/style/src/images/shopping-mall.png b/style/src/images/shopping-mall.png new file mode 100644 index 0000000..c525507 Binary files /dev/null and b/style/src/images/shopping-mall.png differ diff --git a/style/src/images/test_clothes1.png b/style/src/images/test_clothes1.png new file mode 100644 index 0000000..188bc37 Binary files /dev/null and b/style/src/images/test_clothes1.png differ diff --git a/style/src/images/test_clothes2.png b/style/src/images/test_clothes2.png new file mode 100644 index 0000000..7f3b33b Binary files /dev/null and b/style/src/images/test_clothes2.png differ diff --git a/style/src/images/upload.png b/style/src/images/upload.png new file mode 100644 index 0000000..b6e9e89 Binary files /dev/null and b/style/src/images/upload.png differ