feat: implement CreateOrphanage and Orphanage pages with map integration and styling

This commit is contained in:
2025-06-11 18:32:34 -03:00
parent 2682bfb9c1
commit bcd47dbdc5
5 changed files with 709 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
<svg width="64" height="72" viewBox="0 0 64 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M64 18.271V43.694C64 53.7844 55.5565 61.9651 45.1421 61.9651H43.2347L33.4941 71.3963C33.1127 71.7659 32.5849 72 32 72C31.4596 72 30.97 71.809 30.5949 71.4887L30.4296 71.3285L30.4232 71.3224L20.7653 61.9651H18.8516C8.44347 61.9651 0 53.7844 0 43.694V18.271C0 8.1807 8.44347 0 18.8579 0H45.1421C55.5565 0 64 8.1807 64 18.271Z" fill="#FFD666"/>
<path d="M14.2697 35.219C12.4132 35.219 11.355 37.2687 12.4322 38.7487C16.6205 44.5139 23.5462 48.2728 31.3717 48.2728C39.1972 48.2728 46.1166 44.5077 50.3049 38.7487C51.3821 37.2687 50.324 35.219 48.4674 35.219H14.2697Z" fill="white"/>
<path d="M25.1432 26.4937H13.9405V21.0196C13.9405 17.9976 16.4497 15.5454 19.5419 15.5454C22.634 15.5454 25.1432 17.9976 25.1432 21.0196V26.4937Z" fill="white"/>
<path d="M48.803 26.4937H37.6002V21.0196C37.6002 17.9976 40.1094 15.5454 43.2016 15.5454C46.2938 15.5454 48.803 17.9976 48.803 21.0196V26.4937Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1,210 @@
import React, { useState, FormEvent, ChangeEvent } from "react";
import { useNavigate } from "react-router-dom";
import { FiPlus } from "react-icons/fi";
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
import { toast } from 'react-toastify';
import mapMarkerImg from '../images/map_marker.svg';
import '../styles/create-orphanage.css';
import Sidebar from "../components/Sidebar";
import api from "../services/api";
export default function CreateOrphanage() {
const { isLoaded } = useJsApiLoader({
id: 'google-map-script',
googleMapsApiKey: process.env.REACT_APP_MAPS_API_KEY!
});
const navigate = useNavigate();
const [position, setPosition] = useState({ lat: 0, lng: 0 });
const [name, setName] = useState('');
const [about, setAbout] = useState('');
const [instructions, setInstructions] = useState('');
const [opening_hours, setOpeningHours] = useState('');
const [open_on_weekends, setOpenOnWeekends] = useState(true);
const [phone, setPhone] = useState('');
const [images, setImages] = useState<File[]>([]);
const [previewImages, setPreviewImages] = useState<string[]>([]);
function handleMapClick(event: google.maps.MapMouseEvent) {
const { latLng } = event;
if (latLng) {
setPosition({
lat: latLng.lat(),
lng: latLng.lng(),
});
}
}
function handleSelectImages(event: ChangeEvent<HTMLInputElement>) {
if (!event.target.files) {
return;
}
const selectedImages = Array.from(event.target.files);
setImages(selectedImages);
const selectedImagesPreview = selectedImages.map(image => {
return URL.createObjectURL(image);
});
setPreviewImages(selectedImagesPreview);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!name.trim()) {
toast.error('O campo "Nome" é obrigatório.');
return;
}
if (!about.trim()) {
toast.error('O campo "Sobre" é obrigatório.');
return;
}
if (!opening_hours.trim()) {
toast.error('O campo "Horário de funcionamento" é obrigatório.');
return;
}
if (position.lat === 0) {
toast.error('Por favor, selecione a localização do orfanato no mapa.');
return;
}
if (images.length === 0) {
toast.error('Por favor, adicione pelo menos uma foto do orfanato.');
return;
}
if (!phone.trim()) {
toast.error('O campo "Número de Whatsapp" é obrigatório.');
return;
}
const { lat, lng } = position;
const data = new FormData();
data.append('name', name);
data.append('about', about);
data.append('latitude', String(lat));
data.append('longitude', String(lng));
data.append('instructions', instructions);
data.append('opening_hours', opening_hours);
data.append('open_on_weekends', String(open_on_weekends));
data.append('phone', phone);
images.forEach(image => {
data.append('images', image);
});
try {
await api.post('orphanages', data);
toast.success('Cadastro realizado com sucesso!');
navigate('/app');
} catch (error) {
toast.error('Erro ao realizar o cadastro. Tente novamente.');
console.error('Failed to create orphanage:', error);
}
}
if (!isLoaded) {
return <div>Loading Map...</div>;
}
return (
<div id="page-create-orphanage">
<Sidebar />
<main>
<form onSubmit={handleSubmit} className="create-orphanage-form">
<fieldset>
<legend>Dados</legend>
<GoogleMap
mapContainerStyle={{ width: '100%', height: 280 }}
center={{ lat: -23.9550537, lng: -46.3204442 }}
zoom={12}
onClick={handleMapClick}
>
{position.lat !== 0 && (
<Marker
position={{ lat: position.lat, lng: position.lng }}
icon={{
url: mapMarkerImg,
scaledSize: new window.google.maps.Size(58, 68),
}}
/>
)}
</GoogleMap>
<div className="input-block">
<label htmlFor="name">Nome</label>
<input id="name" value={name} maxLength={50} onChange={e => setName(e.target.value)} />
</div>
<div className="input-block">
<label htmlFor="about">Sobre <span>Máximo de 300 caracteres</span></label>
<textarea id="about" maxLength={300} value={about} onChange={e => setAbout(e.target.value)} />
</div>
<div className="input-block">
<label htmlFor="phone">Número de Whatsapp</label>
<input
id="phone"
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</div>
<div className="input-block">
<label htmlFor="images">Fotos</label>
<div className="images-container">
{previewImages.map(image => (
<img key={image} src={image} alt={name}/>
))}
<label htmlFor="image[]" className="new-image">
<FiPlus size={24} color="#15b6d6" />
</label>
</div>
<input multiple onChange={handleSelectImages} type="file" id="image[]" />
</div>
</fieldset>
<fieldset>
<legend>Visitação</legend>
<div className="input-block">
<label htmlFor="instructions">Instruções</label>
<textarea id="instructions" maxLength={300} value={instructions} onChange={e => setInstructions(e.target.value)} />
</div>
<div className="input-block">
<label htmlFor="opening_hours">Horário de funcionamento</label>
<input id="opening_hours" maxLength={20} value={opening_hours} onChange={e => setOpeningHours(e.target.value)} />
</div>
<div className="input-block">
<label htmlFor="open_on_weekends">Atende fim de semana</label>
<div className="button-select">
<button
type="button"
className={open_on_weekends ? 'active' : ''}
onClick={() => setOpenOnWeekends(true)}
>
Sim
</button>
<button
type="button"
className={!open_on_weekends ? 'active' : ''}
onClick={() => setOpenOnWeekends(false)}
>
Não
</button>
</div>
</div>
</fieldset>
<button className="confirm-button" type="submit">
Confirmar
</button>
</form>
</main>
</div>
);
}

150
web/src/pages/Orphanage.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { FaWhatsapp } from "react-icons/fa";
import { FiClock, FiInfo } from "react-icons/fi";
import { useParams } from 'react-router-dom';
import { GoogleMap, useJsApiLoader, Marker } from "@react-google-maps/api";
import mapMarkerImg from '../images/map_marker.svg';
import '../styles/orphanage.css';
import Sidebar from '../components/Sidebar';
import api from "../services/api";
interface Orphanage {
latitude: number;
longitude: number;
name: string;
about: string;
instructions: string;
opening_hours: string;
open_on_weekends: boolean;
phone: string;
images: Array<{
id: number;
url: string;
}>;
}
export default function Orphanage() {
const { isLoaded } = useJsApiLoader({
id: 'google-map-script',
googleMapsApiKey: process.env.REACT_APP_MAPS_API_KEY!
});
const { id } = useParams();
const [orphanage, setOrphanage] = useState<Orphanage>();
const [activeImageIndex, setActiveImageIndex] = useState(0);
useEffect(() => {
api.get(`orphanages/${id}`).then(response => {
setOrphanage(response.data);
});
}, [id]);
if (!orphanage || !isLoaded) {
return <p>Carregando...</p>;
}
function formatPhoneNumberForWhatsApp(phoneNumber: string) {
const digitsOnly = phoneNumber.replace(/\D/g, '');
return `55${digitsOnly}`;
}
return (
<div id="page-orphanage">
<Sidebar />
<main>
<div className="orphanage-details">
<img src={orphanage.images[activeImageIndex].url} alt={orphanage.name} />
<div className="images">
{orphanage.images.map((image, index) => (
<button
key={image.id}
className={activeImageIndex === index ? 'active' : ''}
type="button"
onClick={() => setActiveImageIndex(index)}
>
<img src={image.url} alt={orphanage.name} />
</button>
))}
</div>
<div className="orphanage-details-content">
<h1>{orphanage.name}</h1>
<p>{orphanage.about}</p>
<div className="map-container">
<GoogleMap
mapContainerStyle={{ width: '100%', height: 280, borderRadius: '20px' }}
center={{ lat: orphanage.latitude, lng: orphanage.longitude }}
zoom={16}
options={{
draggable: false,
zoomControl: false,
scrollwheel: false,
disableDoubleClickZoom: true,
}}
>
<Marker
position={{ lat: orphanage.latitude, lng: orphanage.longitude }}
icon={{
url: mapMarkerImg,
scaledSize: new window.google.maps.Size(58, 68),
}}
/>
</GoogleMap>
<footer>
<a
target="_blank"
rel="noopener noreferrer"
href={`https://www.google.com/maps/dir/?api=1&destination=$...${orphanage.latitude},${orphanage.longitude}`}
>
Ver rotas no Google Maps
</a>
</footer>
</div>
<hr />
<h2>Instruções para visita</h2>
<p>{orphanage.instructions}</p>
<div className="open-details">
<div className="hour">
<FiClock size={32} color="#15B6D6" />
Segunda à Sexta <br />
{orphanage.opening_hours}
</div>
{orphanage.open_on_weekends ? (
<div className="open-on-weekends">
<FiInfo size={32} color="#39CC83" />
Atendemos <br />
fim de semana
</div>
) : (
<div className="open-on-weekends dont-open">
<FiInfo size={32} color="#FF669D" />
Não atendemos <br />
fim de semana
</div>
)}
</div>
<a
href={`https://wa.me/${formatPhoneNumberForWhatsApp(orphanage.phone)}`}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<button className="contact-button">
<FaWhatsapp size={20} color="#FFF" />
Entrar em contato
</button>
</a>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,175 @@
#page-create-orphanage {
display: flex;
}
#page-create-orphanage main {
flex: 1;
}
form.create-orphanage-form {
width: 700px;
margin: 64px auto;
background: #FFFFFF;
border: 1px solid #D3E2E5;
border-radius: 20px;
padding: 64px 80px;
overflow: hidden;
}
form.create-orphanage-form .leaflet-container {
border-radius: 20px;
border: 1px solid #D3E2E5;
margin-bottom: 40px;
}
form.create-orphanage-form fieldset {
border: 0;
}
form.create-orphanage-form fieldset + fieldset {
margin-top: 80px;
}
form.create-orphanage-form fieldset legend {
width: 100%;
font-size: 32px;
line-height: 34px;
color: #5C8599;
font-weight: 700;
border-bottom: 1px solid #D3E2E5;
margin-bottom: 40px;
padding-bottom: 24px;
}
form.create-orphanage-form .input-block {
margin-top: 24px;
}
form.create-orphanage-form .input-block label {
display: flex;
color: #8FA7B3;
margin-bottom: 8px;
line-height: 24px;
}
form.create-orphanage-form .input-block label span {
font-size: 14px;
color: #8FA7B3;
margin-left: 24px;
line-height: 24px;
}
form.create-orphanage-form .input-block input,
form.create-orphanage-form .input-block textarea {
width: 100%;
background: #F5F8FA;
border: 1px solid #D3E2E5;
border-radius: 20px;
outline: none;
color: #5C8599;
}
form.create-orphanage-form .input-block input {
height: 64px;
padding: 0 16px;
}
form.create-orphanage-form .input-block textarea {
min-height: 120px;
max-height: 240px;
resize: vertical;
padding: 16px;
line-height: 28px;
}
/* --- NEW STYLES FOR IMAGE UPLOAD --- */
form.create-orphanage-form .input-block input[type="file"] {
display: none;
}
form.create-orphanage-form .input-block .images-container {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-gap: 16px;
}
form.create-orphanage-form .input-block .images-container img {
width: 100%;
height: 96px;
object-fit: cover;
border-radius: 20px;
}
form.create-orphanage-form .input-block .images-container .new-image {
height: 96px;
background: #F5F8FA;
border: 1px dashed #96D2F0;
border-radius: 20px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
/* --- END NEW STYLES --- */
form.create-orphanage-form .input-block .button-select {
display: grid;
grid-template-columns: 1fr 1fr;
}
form.create-orphanage-form .input-block .button-select button {
height: 64px;
background: #F5F8FA;
border: 1px solid #D3E2E5;
color: #5C8599;
cursor: pointer;
}
form.create-orphanage-form .input-block .button-select button.active {
background: #EDFFF6;
border: 1px solid #A1E9C5;
color: #37C77F;
}
form.create-orphanage-form .input-block .button-select button:first-child {
border-radius: 20px 0px 0px 20px;
}
form.create-orphanage-form .input-block .button-select button:last-child {
border-radius: 0 20px 20px 0;
border-left: 0;
}
form.create-orphanage-form button.confirm-button {
margin-top: 64px;
width: 100%;
height: 64px;
border: 0;
cursor: pointer;
background: #3CDC8C;
border-radius: 20px;
color: #FFFFFF;
font-weight: 800;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.2s;
}
form.create-orphanage-form button.confirm-button svg {
margin-right: 16px;
}
form.create-orphanage-form button.confirm-button:hover {
background: #36CF82;
}

View File

@@ -0,0 +1,168 @@
#page-orphanage {
display: flex;
min-height: 100vh;
}
#page-orphanage main {
flex: 1;
}
.orphanage-details {
width: 700px;
margin: 64px auto;
background: #FFFFFF;
border: 1px solid #D3E2E5;
border-radius: 20px;
overflow: hidden;
}
.orphanage-details > img {
width: 100%;
height: 300px;
object-fit: cover;
}
.orphanage-details .images {
display: grid;
grid-template-columns: repeat(6 ,1fr);
column-gap: 16px;
margin: 16px 40px 0;
}
.orphanage-details .images button {
border: 0;
height: 88px;
background: none;
cursor: pointer;
border-radius: 20px;
overflow: hidden;
outline: none;
opacity: 0.6;
}
.orphanage-details .images button.active {
opacity: 1;
}
.orphanage-details .images button img {
width: 100%;
height: 88px;
object-fit: cover;
}
.orphanage-details .orphanage-details-content {
padding: 80px;
}
.orphanage-details .orphanage-details-content h1 {
color: #4D6F80;
font-size: 54px;
line-height: 54px;
margin-bottom: 8px;
}
.orphanage-details .orphanage-details-content p {
line-height: 28px;
color: #5C8599;
margin-top: 24px;
}
.orphanage-details .orphanage-details-content .map-container {
margin-top: 64px;
background: #E6F7FB;
border: 1px solid #B3DAE2;
border-radius: 20px;
}
.orphanage-details .orphanage-details-content .map-container footer {
padding: 20px 0;
text-align: center;
}
.orphanage-details .orphanage-details-content .map-container footer a {
line-height: 24px;
color: #0089A5;
text-decoration: none;
}
.orphanage-details .orphanage-details-content .map-container .leaflet-container {
border-bottom: 1px solid #DDE3F0;
border-radius: 20px;
}
.orphanage-details .orphanage-details-content hr {
width: 100%;
height: 1px;
border: 0;
background: #D3E2E6;
margin: 64px 0;
}
.orphanage-details .orphanage-details-content h2 {
font-size: 36px;
line-height: 46px;
color: #4D6F80;
}
.orphanage-details .orphanage-details-content .open-details {
margin-top: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 20px;
}
.orphanage-details .orphanage-details-content .open-details div {
padding: 32px 24px;
border-radius: 20px;
line-height: 28px;
}
.orphanage-details .orphanage-details-content .open-details div svg {
display: block;
margin-bottom: 20px;
}
.orphanage-details .orphanage-details-content .open-details div.hour {
background: linear-gradient(149.97deg, #E6F7FB 8.13%, #FFFFFF 92.67%);
border: 1px solid #B3DAE2;
color: #5C8599;
}
.orphanage-details .orphanage-details-content .open-details div.open-on-weekends {
background: linear-gradient(154.16deg, #EDFFF6 7.85%, #FFFFFF 91.03%);
border: 1px solid #A1E9C5;
color: #37C77F;
}
.orphanage-details .orphanage-details-content button.contact-button {
margin-top: 64px;
width: 100%;
height: 64px;
border: 0;
cursor: pointer;
background: #3CDC8C;
border-radius: 20px;
color: #FFFFFF;
font-weight: 800;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.2s;
}
.orphanage-details .orphanage-details-content button.contact-button svg {
margin-right: 16px;
}
.orphanage-details .orphanage-details-content button.contact-button:hover {
background: #36CF82;
}