initial commit
This commit is contained in:
75
frontend/src/App.css
Normal file
75
frontend/src/App.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Reset some default styles */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Responsive utilities */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
19
frontend/src/App.jsx
Normal file
19
frontend/src/App.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import Home from './pages/Home';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
87
frontend/src/components/Button.css
Normal file
87
frontend/src/components/Button.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 205, 178, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--color-focus);
|
||||
}
|
||||
|
||||
.btn-outline:focus {
|
||||
box-shadow: 0 0 0 3px var(--color-focus);
|
||||
}
|
||||
29
frontend/src/components/Button.jsx
Normal file
29
frontend/src/components/Button.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import './Button.css';
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const buttonClasses = `btn btn-${variant} btn-${size} ${className}`.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={buttonClasses}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
69
frontend/src/components/Card.css
Normal file
69
frontend/src/components/Card.css
Normal file
@@ -0,0 +1,69 @@
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(109, 104, 117, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--color-text-accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Card without image */
|
||||
.card:not(:has(.card-image)) .card-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card:not(:has(.card-image)) .card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
31
frontend/src/components/Card.jsx
Normal file
31
frontend/src/components/Card.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import './Card.css';
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
imageAlt,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={`card ${className}`.trim()} {...props}>
|
||||
{image && (
|
||||
<div className="card-image">
|
||||
<img src={image} alt={imageAlt || title} />
|
||||
</div>
|
||||
)}
|
||||
<div className="card-content">
|
||||
{title && <h3 className="card-title">{title}</h3>}
|
||||
{subtitle && <p className="card-subtitle">{subtitle}</p>}
|
||||
<div className="card-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
76
frontend/src/components/Footer.css
Normal file
76
frontend/src/components/Footer.css
Normal file
@@ -0,0 +1,76 @@
|
||||
.footer {
|
||||
background: linear-gradient(135deg, var(--color-dark) 0%, var(--color-secondary-dark) 100%);
|
||||
color: var(--color-text-primary-light);
|
||||
padding: 3rem 0 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-section ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-section ul li a {
|
||||
color: var(--color-text-primary-light);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid var(--color-secondary-dark);
|
||||
padding-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
40
frontend/src/components/Footer.jsx
Normal file
40
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import './Footer.css';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-container">
|
||||
<div className="footer-content">
|
||||
<div className="footer-section">
|
||||
<h3>Phun</h3>
|
||||
<p>{t('footerDescription')}</p>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Links</h4>
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="/contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Connect</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com" target="_blank" rel="noopener noreferrer">GitHub</a></li>
|
||||
<li><a href="https://twitter.com" target="_blank" rel="noopener noreferrer">Twitter</a></li>
|
||||
<li><a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">LinkedIn</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2024 Phun. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
68
frontend/src/components/Header.css
Normal file
68
frontend/src/components/Header.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: var(--color-dark);
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 10px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, var(--color-dark), var(--color-secondary-dark));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-dark);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
27
frontend/src/components/Header.jsx
Normal file
27
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import LanguageSwitcher from './LanguageSwitcher';
|
||||
import './Header.css';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-container">
|
||||
<h1 className="header-title">Phun</h1>
|
||||
<nav className="header-nav">
|
||||
<ul className="nav-list">
|
||||
<li><Link to="/" className="nav-link">{t('home')}</Link></li>
|
||||
<li><Link to="/about" className="nav-link">{t('about')}</Link></li>
|
||||
<li><Link to="/contact" className="nav-link">{t('contact')}</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
89
frontend/src/components/LanguageSwitcher.css
Normal file
89
frontend/src/components/LanguageSwitcher.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.language-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: rgba(109, 104, 117, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
background: rgba(255, 205, 178, 0.2);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
box-shadow: 0 2px 8px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.language-btn:focus {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.language-buttons {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
background: rgba(255, 205, 178, 0.15);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.language-switcher {
|
||||
margin-left: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.language-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/LanguageSwitcher.jsx
Normal file
35
frontend/src/components/LanguageSwitcher.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import './LanguageSwitcher.css';
|
||||
|
||||
const LanguageSwitcher = () => {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
const handleLanguageChange = (newLanguage) => {
|
||||
setLanguage(newLanguage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="language-switcher">
|
||||
<span className="language-label">{t('language')}:</span>
|
||||
<div className="language-buttons">
|
||||
<button
|
||||
className={`language-btn ${language === 'en' ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
{t('english')}
|
||||
</button>
|
||||
<button
|
||||
className={`language-btn ${language === 'fi' ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageChange('fi')}
|
||||
aria-label="Vaihda suomeksi"
|
||||
>
|
||||
{t('finnish')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
5
frontend/src/components/index.js
Normal file
5
frontend/src/components/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as LanguageSwitcher } from './LanguageSwitcher';
|
||||
103
frontend/src/contexts/LanguageContext.jsx
Normal file
103
frontend/src/contexts/LanguageContext.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
const LanguageContext = createContext();
|
||||
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Header
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
|
||||
// CTA Section
|
||||
readyToStart: 'Ready to get started?',
|
||||
ctaSubtitle: 'Join thousands of developers building amazing applications with our modern stack.',
|
||||
startBuilding: 'Start Building',
|
||||
|
||||
// Language Switcher
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
finnish: 'Finnish',
|
||||
|
||||
// Search Page
|
||||
searchPlaceholder: 'Enter your search query...',
|
||||
searchButton: 'Search',
|
||||
searchResults: 'Search Results',
|
||||
searchResultsSubtitle: 'Here are the results for your search query.',
|
||||
searchResultsPlaceholder: 'Search results will appear here. Try entering a search term above to get started.',
|
||||
|
||||
// Search Filters
|
||||
dogFriendly: 'Dog Friendly',
|
||||
accessible: 'Accessible',
|
||||
familyFriendly: 'Family Friendly',
|
||||
peopleCount: 'People',
|
||||
priceRange: 'Price',
|
||||
any: 'Any',
|
||||
|
||||
// Footer
|
||||
footerDescription: 'Find fun things to do!',
|
||||
},
|
||||
fi: {
|
||||
// Header
|
||||
home: 'Koti',
|
||||
about: 'Tietoja',
|
||||
contact: 'Yhteystiedot',
|
||||
|
||||
|
||||
// CTA Section
|
||||
readyToStart: 'Valmiina aloittamaan?',
|
||||
ctaSubtitle: 'Liity tuhansien kehittäjien joukkoon, jotka rakentavat upeita sovelluksia modernilla teknologiapinoamme.',
|
||||
startBuilding: 'Aloita rakentaminen',
|
||||
|
||||
// Language Switcher
|
||||
language: 'Kieli',
|
||||
english: 'Englanti',
|
||||
finnish: 'Suomi',
|
||||
|
||||
// Search Page
|
||||
searchPlaceholder: 'Syötä hakukyselysi...',
|
||||
searchButton: 'Hae',
|
||||
searchResults: 'Hakutulokset',
|
||||
searchResultsSubtitle: 'Tässä ovat hakutulokset kyselysi perusteella.',
|
||||
searchResultsPlaceholder: 'Hakutulokset näkyvät täällä. Kokeile syöttää hakusana yllä aloittaaksesi.',
|
||||
|
||||
// Search Filters
|
||||
dogFriendly: 'Koiraystävällinen',
|
||||
accessible: 'Esteetön',
|
||||
familyFriendly: 'Lapsiystävällinen',
|
||||
peopleCount: 'Henkilöt',
|
||||
priceRange: 'Hinta',
|
||||
any: 'Mikä tahansa',
|
||||
|
||||
// Footer
|
||||
footerDescription: 'Löydä hauskaa tekemistä!',
|
||||
}
|
||||
};
|
||||
|
||||
export const LanguageProvider = ({ children }) => {
|
||||
const [language, setLanguage] = useState('en');
|
||||
|
||||
const t = (key) => {
|
||||
return translations[language][key] || key;
|
||||
};
|
||||
|
||||
const value = {
|
||||
language,
|
||||
setLanguage,
|
||||
t
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
30
frontend/src/hooks/useLocalStorage.js
Normal file
30
frontend/src/hooks/useLocalStorage.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const useLocalStorage = (key, initialValue) => {
|
||||
// Get from local storage then parse stored json or return initialValue
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that persists the new value to localStorage
|
||||
const setValue = (value) => {
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
|
||||
export default useLocalStorage;
|
||||
89
frontend/src/index.css
Normal file
89
frontend/src/index.css
Normal file
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #6D6875;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Primary Colors */
|
||||
--color-primary: #FFCDB2;
|
||||
--color-primary-dark: #FFB4A2;
|
||||
--color-secondary: #E5989B;
|
||||
--color-secondary-dark: #B5838D;
|
||||
--color-dark: #6D6875;
|
||||
|
||||
/* Background Colors */
|
||||
--color-bg-dark: #6D6875;
|
||||
--color-bg-light: #FFCDB2;
|
||||
--color-bg-card: white;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #6D6875;
|
||||
--color-text-primary-light: rgba(255, 255, 255, 0.87);
|
||||
--color-text-secondary: #B5838D;
|
||||
--color-text-accent: #E5989B;
|
||||
|
||||
/* Interactive Colors */
|
||||
--color-focus: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-primary: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-secondary: rgba(229, 152, 155, 0.3);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: var(--color-bg-dark);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto var(--color-focus);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-secondary-dark);
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
337
frontend/src/pages/Home.css
Normal file
337
frontend/src/pages/Home.css
Normal file
@@ -0,0 +1,337 @@
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
background: linear-gradient(rgba(109, 104, 117, 0.4), rgba(109, 104, 117, 0.4)), url('https://images.unsplash.com/photo-1429305336325-b84ace7eba3b?q=80&w=1770&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
color: white;
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 3rem 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(45deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Search Toolbar in Hero */
|
||||
.search-toolbar {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Search Input Container */
|
||||
.search-input-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 50px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 8px 32px var(--color-shadow-primary);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-container:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 25px;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.search-button:focus {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Filters Container */
|
||||
.filters-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(109, 104, 117, 0.1);
|
||||
}
|
||||
|
||||
.filter-label:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(109, 104, 117, 0.15);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
background: rgba(229, 152, 155, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
background: rgba(229, 152, 155, 0.2);
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 5rem 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 3rem 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-button {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
min-width: 200px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
min-width: 180px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search-toolbar {
|
||||
background: rgba(109, 104, 117, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.filter-label:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.features {
|
||||
background: var(--color-bg-dark);
|
||||
}
|
||||
}
|
||||
150
frontend/src/pages/Home.jsx
Normal file
150
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Footer, Button, Card } from '../components';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import './Home.css';
|
||||
|
||||
const Home = () => {
|
||||
const { t } = useLanguage();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
dogFriendly: false,
|
||||
accessible: false,
|
||||
familyFriendly: false,
|
||||
peopleCount: '',
|
||||
priceRange: ''
|
||||
});
|
||||
|
||||
const handleGetStarted = () => {
|
||||
alert('Get Started button clicked!');
|
||||
};
|
||||
|
||||
const handleLearnMore = () => {
|
||||
alert('Learn More button clicked!');
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterName, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// Handle search logic here
|
||||
console.log('Search query:', searchQuery);
|
||||
console.log('Filters:', filters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Header />
|
||||
|
||||
<main className="main-content">
|
||||
{/* Hero Section */}
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
|
||||
{/* Search Toolbar */}
|
||||
<div className="search-toolbar">
|
||||
<div className="toolbar-content">
|
||||
{/* Search Input */}
|
||||
<div className="search-input-container">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button className="search-button" onClick={handleSearch}>
|
||||
{t('searchButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters-container">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.dogFriendly}
|
||||
onChange={(e) => handleFilterChange('dogFriendly', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('dogFriendly')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.accessible}
|
||||
onChange={(e) => handleFilterChange('accessible', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('accessible')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.familyFriendly}
|
||||
onChange={(e) => handleFilterChange('familyFriendly', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('familyFriendly')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<span className="filter-text">{t('peopleCount')}:</span>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={filters.peopleCount}
|
||||
onChange={(e) => handleFilterChange('peopleCount', e.target.value)}
|
||||
>
|
||||
<option value="">{t('any')}</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5+">5+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<span className="filter-text">{t('priceRange')}:</span>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={filters.priceRange}
|
||||
onChange={(e) => handleFilterChange('priceRange', e.target.value)}
|
||||
>
|
||||
<option value="">{t('any')}</option>
|
||||
<option value="€">€</option>
|
||||
<option value="€€">€€</option>
|
||||
<option value="€€€">€€€</option>
|
||||
<option value="€€€€">€€€€</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
84
frontend/src/utils/api.js
Normal file
84
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponse = async (response) => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
const isJson = contentType && contentType.includes('application/json');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = isJson ? await response.json() : await response.text();
|
||||
throw new ApiError(
|
||||
errorData.message || `HTTP error! status: ${response.status}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return isJson ? response.json() : response.text();
|
||||
};
|
||||
|
||||
export const api = {
|
||||
async get(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async post(endpoint, data = {}, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async put(endpoint, data = {}, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
Reference in New Issue
Block a user