Compare commits

...

8 Commits

Author SHA1 Message Date
684d46c5b2 test: added activity repo test
All checks were successful
Backend CI / Run Maven Tests (pull_request) Successful in 1m20s
2025-08-11 18:33:54 +00:00
b4cd05e7b2 Updated datamodel picture in the readme
Reviewed-on: #6
Co-authored-by: Jarno Kiesiläinen <jarnokie@gmail.com>
Co-committed-by: Jarno Kiesiläinen <jarnokie@gmail.com>
2025-08-07 16:36:08 +00:00
286699a1a8 Initial implementation for the backend persistence of activities (#5)
All checks were successful
Backend CI / Run Maven Tests (push) Successful in 27s
Reviewed-on: #5
Co-authored-by: Jarno Kiesiläinen <jarnokie@gmail.com>
Co-committed-by: Jarno Kiesiläinen <jarnokie@gmail.com>
2025-08-07 16:17:46 +00:00
c3764176ac Backend project initialized
All checks were successful
Backend CI / Run Maven Tests (push) Successful in 27s
Co-authored-by: Jarno Kiesiläinen <jarnokie@gmail.com>
Co-committed-by: Jarno Kiesiläinen <jarnokie@gmail.com>
2025-08-02 19:27:17 +00:00
47ac6f4c74 Merge pull request 'feat: added mock search results' (#3) from feature/mock-search-results into main
All checks were successful
Frontend CI / Lint and Build Frontend (push) Successful in 20s
Reviewed-on: http://git.naali.duckdns.org/jarno/vibing/pulls/3
2025-08-02 16:51:57 +00:00
928f65f9c5 build: downgraded actions for compatibility
All checks were successful
Frontend CI / Lint and Build Frontend (pull_request) Successful in 1m25s
2025-08-02 16:43:45 +03:00
19cba1606b fix: fixed eslint errors
Some checks failed
Frontend CI / Lint and Build Frontend (pull_request) Failing after 21s
2025-08-02 16:37:14 +03:00
9a11b4ea7e feat: added mock search results
Some checks failed
Frontend CI / Lint and Build Frontend (pull_request) Failing after 1m35s
2025-08-02 15:29:18 +03:00
33 changed files with 1968 additions and 101 deletions

View File

@@ -0,0 +1,32 @@
## Brief overview
Project-specific frontend color theme guidelines for the vibing application. Defines a cohesive peach/coral/pink color palette with specific CSS custom properties, component styling patterns, and accessibility requirements.
## Color palette usage
- Always use the defined peach-coral color scheme: Primary Peach (#FFCDB2), Secondary Coral (#FFB4A2), Light Pink (#E5989B), Medium Mauve (#B5838D), Dark Purple (#6D6875)
- Implement CSS custom properties in src/index.css for all color definitions
- Use semantic color naming: --color-primary, --color-secondary, --color-bg-dark, --color-text-primary, etc.
- Support both dark and light modes using @media (prefers-color-scheme: light)
## Component styling patterns
- Buttons: Use gradient backgrounds, implement translateY(-2px) hover animations, apply consistent focus states with peach outline
- Cards: White backgrounds with subtle shadows, translateY(-8px) hover animations, 16px border radius, scale(1.05) image hover effects
- Links: Primary color #FFCDB2, hover colors #E5989B (dark mode) or #B5838D (light mode), no text decoration by default
- Use semantic CSS class names: .btn-primary, .btn-secondary, .btn-outline, .btn-ghost
## Interactive states and animations
- Apply subtle lift animations using transform: translateY(-2px) for hover effects
- Use rgba(255, 205, 178, 0.3) for focus rings and primary shadows
- Use rgba(229, 152, 155, 0.3) for secondary shadows
- Ensure hover states are distinct from focus states for accessibility
## File organization structure
- Place global styles and CSS custom properties in src/index.css
- Create individual .css files in src/components/ for component-specific styles
- Use src/App.css for utility classes
- Consider src/styles/ directory for additional theme files when needed
## Accessibility requirements
- Maintain minimum contrast ratios: 4.5:1 for normal text, 3:1 for large text
- Use --color-focus for focus indicators
- Test with color blindness simulators
- Ensure all interactive elements have proper focus states

View File

@@ -0,0 +1,38 @@
## Brief overview
Project-specific git workflow guidelines emphasizing feature branch development, conventional commit messages, and proper branch management practices. Never work directly on main/master branches.
## Branch management strategy
- Always create feature branches for all development work
- Use kebab-case naming: feature/user-authentication, bugfix/login-validation, hotfix/security-patch, refactor/api-endpoints
- Keep feature branches short-lived (1-3 days ideally)
- One feature per branch, don't mix different types of changes
- Delete branches after successful merge
## Commit message conventions
- Use conventional commit format: type(scope): description
- Use present tense ("add feature" not "added feature")
- Be specific and descriptive in commit messages
- Reference issue numbers when applicable: feat(auth): add OAuth login #123
- Make frequent, small commits with clear purposes
## Development workflow steps
- Always start by ensuring main branch is up to date: git checkout main && git pull origin main
- Create new feature branch: git checkout -b feature/your-feature-name
- Before pushing, rebase with main: git fetch origin && git rebase origin/main
- Push feature branch: git push origin feature/your-feature-name
- Create Pull Request from feature branch to main
- Only merge after code review approval
## Code review requirements
- Create PR for all changes, no direct commits to main
- Request reviews from team members
- Address all feedback before merging
- Ensure all tests pass before creating PR
- Self-review changes before requesting reviews
- Update documentation when needed
## Emergency procedures
- For critical issues, create hotfix branches from main
- Make minimal necessary changes in hotfixes
- Test thoroughly before creating emergency PR
- Follow same review process even for hotfixes

49
.gitea/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Gitea Actions for Vibing Project
This directory contains Gitea Actions workflows for the Vibing project (both frontend and backend).
## Workflows
### Backend CI (`backend-ci.yml`)
Runs Maven tests on the backend project.
**Triggers:**
- Push to `main` or `develop` branches
- Pull requests to `main` or `develop` branches
- Only when changes are made to files in the `backend/` directory
**What it does:**
1. Checks out the code
2. Sets up Java 21 (Temurin distribution)
3. Caches Maven dependencies for faster builds
4. Runs `mvn test` in the backend directory
5. Uploads test results as artifacts (retained for 7 days)
**Requirements:**
- Java 21
- Maven
- Ubuntu runner
### Frontend CI (`frontend-ci.yml`)
Runs linting and builds the frontend project.
**Triggers:**
- Push to `main` or `develop` branches
- Pull requests to `main` or `develop` branches
- Only when changes are made to files in the `frontend/` directory
**What it does:**
1. Checks out the code
2. Sets up Node.js 18
3. Caches npm dependencies for faster builds
4. Installs dependencies with `npm ci`
5. Runs ESLint for code quality checks
6. Builds the project with `npm run build`
7. Uploads build artifacts (retained for 7 days)
**Requirements:**
- Node.js 18
- npm
- Ubuntu runner

View File

@@ -0,0 +1,44 @@
name: Backend CI
on:
push:
branches: [ main, develop ]
paths:
- 'backend/**'
pull_request:
branches: [ main, develop ]
paths:
- 'backend/**'
jobs:
test:
name: Run Maven Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Set up Maven
uses: stCarolas/setup-maven@v5
with:
maven-version: '3.6.3'
- name: Run Maven tests
working-directory: ./backend
run: mvn test
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: backend-test-results
path: backend/target/surefire-reports/
retention-days: 7

View File

@@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
@@ -39,7 +39,7 @@ jobs:
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: frontend-build
path: frontend/dist

32
README.md Normal file
View File

@@ -0,0 +1,32 @@
# Vibing application
## Data model
```mermaid
classDiagram
class Activity {
+Long id
+String name
+Location location
+Integer priceRange
+List~String~ tags
+String description
+LocalDateTime createdAt
+LocalDateTime updatedAt
}
class Location {
+Long id
+String name
+String address
+String city
+String country
+String postalCode
+Double latitude
+Double longitude
+LocalDateTime createdAt
+LocalDateTime updatedAt
}
Activity <-- Location : belongs to
```

99
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,99 @@
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# IDE files
.idea/
*.iws
*.iml
*.ipr
.vscode/
.settings/
.project
.classpath
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Application specific
application-local.yml
application-prod.yml
*.db
*.sqlite
# Logs
logs/
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

193
backend/README.md Normal file
View File

@@ -0,0 +1,193 @@
# Vibing Backend
A REST API backend service built with Spring Boot, Java 21, and Maven for the Vibing application.
## Features
- **RESTful API**: Complete CRUD operations for user management
- **Spring Boot 3.2.0**: Latest Spring Boot version with Java 21 support
- **JPA/Hibernate**: Database persistence with H2 in-memory database
- **Spring Security**: Basic security configuration (extensible for production)
- **API Documentation**: Swagger/OpenAPI 3 documentation
- **Validation**: Input validation using Bean Validation
- **Testing**: JUnit 5 test framework
## Prerequisites
- Java 21 or higher
- Maven 3.6 or higher
- IDE (IntelliJ IDEA, Eclipse, VS Code, etc.)
## Getting Started
### 1. Clone the Repository
```bash
git clone <repository-url>
cd vibing/backend
```
### 2. Build the Project
```bash
mvn clean install
```
### 3. Run the Application
```bash
mvn spring-boot:run
```
The application will start on `http://localhost:8080`
## API Endpoints
### Base URL
- **API Base**: `http://localhost:8080/api`
- **Health Check**: `http://localhost:8080/api/health`
- **Swagger UI**: `http://localhost:8080/swagger-ui.html`
- **H2 Console**: `http://localhost:8080/h2-console`
### User Management Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/users` | Get all users |
| GET | `/api/users/{id}` | Get user by ID |
| GET | `/api/users/username/{username}` | Get user by username |
| POST | `/api/users` | Create new user |
| PUT | `/api/users/{id}` | Update user |
| DELETE | `/api/users/{id}` | Delete user |
| GET | `/api/users/check-username/{username}` | Check username availability |
| GET | `/api/users/check-email/{email}` | Check email availability |
### Health Check Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Application health status |
| GET | `/api/health/ping` | Simple ping endpoint |
## Database
The application uses H2 in-memory database for development:
- **URL**: `jdbc:h2:mem:testdb`
- **Username**: `sa`
- **Password**: `password`
- **Console**: `http://localhost:8080/h2-console`
## Project Structure
```
src/
├── main/
│ ├── java/com/vibing/backend/
│ │ ├── VibingBackendApplication.java # Main application class
│ │ ├── controller/ # REST controllers
│ │ │ ├── UserController.java
│ │ │ └── HealthController.java
│ │ ├── service/ # Business logic
│ │ │ └── UserService.java
│ │ ├── repository/ # Data access layer
│ │ │ └── UserRepository.java
│ │ ├── model/ # Entity classes
│ │ │ └── User.java
│ │ ├── config/ # Configuration classes
│ │ │ └── SecurityConfig.java
│ │ └── exception/ # Custom exceptions
│ └── resources/
│ └── application.yml # Application configuration
└── test/
└── java/com/vibing/backend/
└── VibingBackendApplicationTests.java
```
## Configuration
The application configuration is in `src/main/resources/application.yml`:
- **Server Port**: 8080
- **Context Path**: `/api`
- **Database**: H2 in-memory
- **JPA**: Hibernate with create-drop DDL
- **Security**: Basic configuration (allows all user endpoints)
## Development
### Running Tests
```bash
mvn test
```
### Building JAR
```bash
mvn clean package
```
The JAR file will be created in the `target/` directory.
### Running JAR
```bash
java -jar target/vibing-backend-1.0.0.jar
```
## API Documentation
Once the application is running, you can access:
- **Swagger UI**: `http://localhost:8080/swagger-ui.html`
- **OpenAPI JSON**: `http://localhost:8080/api-docs`
## Example API Usage
### Create a User
```bash
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "john_doe",
"email": "john@example.com",
"password": "password123",
"firstName": "John",
"lastName": "Doe"
}'
```
### Get All Users
```bash
curl http://localhost:8080/api/users
```
### Get User by ID
```bash
curl http://localhost:8080/api/users/1
```
## Security
The current security configuration allows all requests to user endpoints for development purposes. For production:
1. Implement proper authentication (JWT, OAuth2, etc.)
2. Add role-based authorization
3. Configure CORS properly
4. Use HTTPS
5. Implement rate limiting
## Contributing
1. Create a feature branch
2. Make your changes
3. Add tests
4. Submit a pull request
## License
This project is licensed under the MIT License.

155
backend/pom.xml Normal file
View File

@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.vibing</groupId>
<artifactId>vibing-backend</artifactId>
<version>1.0.0</version>
<name>Vibing Backend</name>
<description>REST API backend service for Vibing application</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Development Tools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>2.4.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>3.0</version>
<scope>test</scope>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<scope>provided</scope>
</dependency>
<!-- API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<version>11.10.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
</plugins>
</build>
</project>

35
backend/run.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Vibing Backend Run Script
echo "Starting Vibing Backend..."
# Check if Java 21 is available
if ! command -v java &> /dev/null; then
echo "Error: Java is not installed or not in PATH"
exit 1
fi
# Check Java version
JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2 | cut -d'.' -f1)
if [ "$JAVA_VERSION" != "21" ]; then
echo "Warning: Java 21 is recommended. Current version: $JAVA_VERSION"
fi
# Check if Maven is available
if ! command -v mvn &> /dev/null; then
echo "Error: Maven is not installed or not in PATH"
exit 1
fi
echo "Building and running the application..."
echo "The application will be available at:"
echo " - API Base: http://localhost:8080/api"
echo " - Health Check: http://localhost:8080/api/health"
echo " - Swagger UI: http://localhost:8080/swagger-ui.html"
echo " - H2 Console: http://localhost:8080/h2-console"
echo ""
echo "Press Ctrl+C to stop the application"
echo ""
# Run the application
mvn spring-boot:run

View File

@@ -0,0 +1,16 @@
package com.vibing.backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Main Spring Boot application class for the Vibing backend service.
* This class serves as the entry point for the REST API backend.
*/
@SpringBootApplication
public class VibingBackendApplication {
public static void main(String[] args) {
SpringApplication.run(VibingBackendApplication.class, args);
}
}

View File

@@ -0,0 +1,35 @@
package com.vibing.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
/**
* Security configuration for the Vibing backend application.
* This is a basic configuration that can be extended for production use.
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Configure security filter chain.
* For development purposes, this allows all requests.
* In production, you should implement proper authentication and authorization.
*
* @param http the HttpSecurity object
* @return SecurityFilterChain
* @throws Exception if configuration fails
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // Disable CSRF for REST APIa
.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
return http.build();
}
}

View File

@@ -0,0 +1,117 @@
package com.vibing.backend.controller;
import com.vibing.backend.model.Activity;
import com.vibing.backend.service.ActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
/**
* REST controller for Activity-related operations.
*/
@RestController
@RequestMapping("/activities")
@Tag(name = "Activity Management", description = "APIs for managing activities")
@CrossOrigin(origins = "*") // Configure this properly for production
@AllArgsConstructor
public class ActivityController {
private final ActivityService activityService;
/**
* Get all activities.
*
* @return ResponseEntity containing list of all activities
*/
@GetMapping
@Operation(summary = "Get all activities", description = "Retrieve a list of all activities")
public ResponseEntity<List<Activity>> getAllActivities() {
List<Activity> activities = activityService.findAll();
return ResponseEntity.ok(activities);
}
/**
* Get an activity by ID.
*
* @param id the activity ID
* @return ResponseEntity containing the activity if found
*/
@GetMapping("/{id}")
@Operation(summary = "Get activity by ID", description = "Retrieve a specific activity by its ID")
public ResponseEntity<Activity> getActivityById(@PathVariable Long id) {
Optional<Activity> activity = activityService.findById(id);
return activity.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* Get activities by name (case-insensitive search).
*
* @param name the activity name to search for
* @return ResponseEntity containing list of matching activities
*/
@GetMapping("/search/name")
@Operation(summary = "Search activities by name", description = "Search for activities by name (case-insensitive)")
public ResponseEntity<List<Activity>> getActivitiesByName(@RequestParam String name) {
List<Activity> activities = activityService.findByNameContainingIgnoreCase(name);
return ResponseEntity.ok(activities);
}
/**
* Get activities by price range.
*
* @param priceRange the price range to filter by
* @return ResponseEntity containing list of activities with the specified price range
*/
@GetMapping("/search/price-range")
@Operation(summary = "Get activities by price range", description = "Retrieve activities with a specific price range")
public ResponseEntity<List<Activity>> getActivitiesByPriceRange(@RequestParam Integer priceRange) {
List<Activity> activities = activityService.findByPriceRange(priceRange);
return ResponseEntity.ok(activities);
}
/**
* Get activities by location.
*
* @param locationId the location ID to filter by
* @return ResponseEntity containing list of activities at the specified location
*/
@GetMapping("/search/location")
@Operation(summary = "Get activities by location", description = "Retrieve activities at a specific location")
public ResponseEntity<List<Activity>> getActivitiesByLocation(@RequestParam Long locationId) {
List<Activity> activities = activityService.findByLocationId(locationId);
return ResponseEntity.ok(activities);
}
/**
* Get activities by tag.
*
* @param tag the tag to search for
* @return ResponseEntity containing list of activities with the specified tag
*/
@GetMapping("/search/tag")
@Operation(summary = "Get activities by tag", description = "Retrieve activities that have a specific tag")
public ResponseEntity<List<Activity>> getActivitiesByTag(@RequestParam String tag) {
List<Activity> activities = activityService.findByTagsContaining(tag);
return ResponseEntity.ok(activities);
}
/**
* Get activities by maximum price range.
*
* @param maxPriceRange the maximum price range
* @return ResponseEntity containing list of activities with price range <= maxPriceRange
*/
@GetMapping("/search/max-price")
@Operation(summary = "Get activities by maximum price range", description = "Retrieve activities with price range less than or equal to the specified value")
public ResponseEntity<List<Activity>> getActivitiesByMaxPriceRange(@RequestParam Integer maxPriceRange) {
List<Activity> activities = activityService.findByPriceRangeLessThanEqual(maxPriceRange);
return ResponseEntity.ok(activities);
}
}

View File

@@ -0,0 +1,49 @@
package com.vibing.backend.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* Health check controller for monitoring the application status.
*/
@RestController
@RequestMapping("/health")
@Tag(name = "Health Check", description = "APIs for checking application health")
public class HealthController {
/**
* Get the health status of the application.
*
* @return ResponseEntity containing health information
*/
@GetMapping
@Operation(summary = "Health check", description = "Check if the application is running")
public ResponseEntity<Map<String, Object>> healthCheck() {
Map<String, Object> healthInfo = new HashMap<>();
healthInfo.put("status", "UP");
healthInfo.put("timestamp", LocalDateTime.now());
healthInfo.put("service", "Vibing Backend");
healthInfo.put("version", "1.0.0");
return ResponseEntity.ok(healthInfo);
}
/**
* Get a simple ping response.
*
* @return ResponseEntity with "pong" message
*/
@GetMapping("/ping")
@Operation(summary = "Ping", description = "Simple ping endpoint")
public ResponseEntity<String> ping() {
return ResponseEntity.ok("pong");
}
}

View File

@@ -0,0 +1,67 @@
package com.vibing.backend.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* Activity entity representing an activity in the Vibing application.
*/
@Entity
@Table(name = "activities")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Activity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Activity name is required")
@Column(nullable = false)
private String name;
@NotNull(message = "Location is required")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "location_id", nullable = false)
private Location location;
@NotNull(message = "Price range is required")
@Min(value = 1, message = "Price range must be at least 1")
@Max(value = 5, message = "Price range must be at most 5")
@Column(name = "price_range", nullable = false)
private Integer priceRange;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "activity_tags", joinColumns = @JoinColumn(name = "activity_id"))
@Column(name = "tag")
private List<String> tags;
@Column(name = "description")
private String description;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,64 @@
package com.vibing.backend.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.With;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* Location entity representing a location in the Vibing application.
*/
@Entity
@Table(name = "locations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@With
public class Location {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Location name is required")
@Column(nullable = false)
private String name;
@Column
private String address;
@Column
private String city;
@Column
private String country;
@Column(name = "postal_code")
private String postalCode;
@Column(name = "latitude")
private Double latitude;
@Column(name = "longitude")
private Double longitude;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,54 @@
package com.vibing.backend.repository;
import com.vibing.backend.model.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository interface for Activity entity operations.
*/
@Repository
public interface ActivityRepository extends JpaRepository<Activity, Long> {
/**
* Find activities by name (case-insensitive).
*
* @param name the activity name to search for
* @return List of activities matching the name
*/
List<Activity> findByNameContainingIgnoreCase(String name);
/**
* Find activities by price range.
*
* @param priceRange the price range to search for
* @return List of activities with the specified price range
*/
List<Activity> findByPriceRange(Integer priceRange);
/**
* Find activities by location.
*
* @param locationId the location ID to search for
* @return List of activities at the specified location
*/
List<Activity> findByLocationId(Long locationId);
/**
* Find activities by tag.
*
* @param tag the tag to search for
* @return List of activities with the specified tag
*/
List<Activity> findByTagsContaining(String tag);
/**
* Find activities by price range less than or equal to.
*
* @param maxPriceRange the maximum price range
* @return List of activities with price range <= maxPriceRange
*/
List<Activity> findByPriceRangeLessThanEqual(Integer maxPriceRange);
}

View File

@@ -0,0 +1,49 @@
package com.vibing.backend.service;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.vibing.backend.model.Activity;
import com.vibing.backend.repository.ActivityRepository;
import lombok.AllArgsConstructor;
/**
* Service class for managing activities.
*/
@Service
@AllArgsConstructor
public class ActivityService {
private final ActivityRepository activityRepository;
public List<Activity> findAll() {
return activityRepository.findAll();
}
public Optional<Activity> findById(Long id) {
return activityRepository.findById(id);
}
public List<Activity> findByNameContainingIgnoreCase(String name) {
return activityRepository.findByNameContainingIgnoreCase(name);
}
public List<Activity> findByPriceRange(Integer priceRange) {
return activityRepository.findByPriceRange(priceRange);
}
public List<Activity> findByTagsContaining(String tag) {
return activityRepository.findByTagsContaining(tag);
}
public List<Activity> findByLocationId(Long locationId) {
return activityRepository.findByLocationId(locationId);
}
public List<Activity> findByPriceRangeLessThanEqual(Integer maxPriceRange) {
return activityRepository.findByPriceRangeLessThanEqual(maxPriceRange);
}
}

View File

@@ -0,0 +1,56 @@
server:
port: 8080
servlet:
context-path: /api
spring:
application:
name: vibing-backend
# Database Configuration
datasource:
url: jdbc:postgresql://localhost:5432/vibing
username: vibing
password: vibing
driver-class-name: org.postgresql.Driver
# JPA Configuration
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# Jackson Configuration
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
# Logging Configuration
logging:
level:
com.vibing.backend: DEBUG
org.springframework.security: DEBUG
org.springframework.web: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
# API Documentation
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
# Security Configuration
security:
jwt:
secret: your-secret-key-here-change-in-production
expiration: 86400000 # 24 hours in milliseconds

View File

@@ -0,0 +1,35 @@
CREATE SCHEMA IF NOT EXISTS vibing;
CREATE TABLE IF NOT EXISTS vibing.locations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
address TEXT,
city TEXT,
country TEXT,
postal_code TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS vibing.activities (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price_range INT NOT NULL,
location_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_location
FOREIGN KEY(location_id)
REFERENCES vibing.locations(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS vibing.activity_tags (
activity_id INT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY (activity_id) REFERENCES vibing.activities(id) ON DELETE CASCADE,
PRIMARY KEY (activity_id, tag)
);

View File

@@ -0,0 +1,277 @@
package com.vibing.backend;
import com.vibing.backend.model.Activity;
import com.vibing.backend.model.Location;
import com.vibing.backend.repository.ActivityRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for Activity entity using H2 database.
* Tests repository operations and database interactions.
*/
@DataJpaTest
@ActiveProfiles("test")
@TestPropertySource(locations = "classpath:application-test.yml")
public class ActivityRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ActivityRepository activityRepository;
private Location testLocation;
private Activity testActivity;
@BeforeEach
void setUp() {
// Create and persist test location
testLocation = new Location();
testLocation.setName("Test Location");
testLocation.setAddress("123 Test Street");
testLocation.setCity("Test City");
testLocation.setCountry("Test Country");
testLocation.setPostalCode("12345");
testLocation.setLatitude(40.7128);
testLocation.setLongitude(-74.0060);
testLocation = entityManager.persistAndFlush(testLocation);
// Create test activity
testActivity = new Activity();
testActivity.setName("Test Activity");
testActivity.setDescription("A test activity for integration testing");
testActivity.setLocation(testLocation);
testActivity.setPriceRange(3);
testActivity.setTags(Arrays.asList("test", "integration", "fun"));
}
@Test
void shouldSaveAndRetrieveActivity() {
// Given - activity is created in setUp()
// When
Activity savedActivity = activityRepository.save(testActivity);
entityManager.flush();
entityManager.clear();
Optional<Activity> retrievedActivity = activityRepository.findById(savedActivity.getId());
// Then
assertThat(retrievedActivity).isPresent();
assertThat(retrievedActivity.get().getName()).isEqualTo("Test Activity");
assertThat(retrievedActivity.get().getDescription()).isEqualTo("A test activity for integration testing");
assertThat(retrievedActivity.get().getPriceRange()).isEqualTo(3);
assertThat(retrievedActivity.get().getLocation().getName()).isEqualTo("Test Location");
assertThat(retrievedActivity.get().getTags()).containsExactlyInAnyOrder("test", "integration", "fun");
assertThat(retrievedActivity.get().getCreatedAt()).isNotNull();
assertThat(retrievedActivity.get().getUpdatedAt()).isNotNull();
}
@Test
void shouldFindActivitiesByNameContainingIgnoreCase() {
// Given
Activity activity1 = new Activity();
activity1.setName("Beach Volleyball");
activity1.setLocation(testLocation);
activity1.setPriceRange(2);
activity1.setTags(Arrays.asList("sport", "beach"));
Activity activity2 = new Activity();
activity2.setName("Mountain Hiking");
activity2.setLocation(testLocation);
activity2.setPriceRange(1);
activity2.setTags(Arrays.asList("outdoor", "hiking"));
activityRepository.saveAll(Arrays.asList(activity1, activity2));
entityManager.flush();
// When
List<Activity> beachActivities = activityRepository.findByNameContainingIgnoreCase("beach");
List<Activity> mountainActivities = activityRepository.findByNameContainingIgnoreCase("MOUNTAIN");
// Then
assertThat(beachActivities).hasSize(1);
assertThat(beachActivities.get(0).getName()).isEqualTo("Beach Volleyball");
assertThat(mountainActivities).hasSize(1);
assertThat(mountainActivities.get(0).getName()).isEqualTo("Mountain Hiking");
}
@Test
void shouldFindActivitiesByPriceRange() {
// Given
Activity cheapActivity = new Activity();
cheapActivity.setName("Free Walking Tour");
cheapActivity.setLocation(testLocation);
cheapActivity.setPriceRange(1);
cheapActivity.setTags(Arrays.asList("free", "walking"));
Activity expensiveActivity = new Activity();
expensiveActivity.setName("Luxury Spa Day");
expensiveActivity.setLocation(testLocation);
expensiveActivity.setPriceRange(5);
expensiveActivity.setTags(Arrays.asList("luxury", "spa"));
activityRepository.saveAll(Arrays.asList(cheapActivity, expensiveActivity));
entityManager.flush();
// When
List<Activity> cheapActivities = activityRepository.findByPriceRange(1);
List<Activity> expensiveActivities = activityRepository.findByPriceRange(5);
// Then
assertThat(cheapActivities).hasSize(1);
assertThat(cheapActivities.get(0).getName()).isEqualTo("Free Walking Tour");
assertThat(expensiveActivities).hasSize(1);
assertThat(expensiveActivities.get(0).getName()).isEqualTo("Luxury Spa Day");
}
@Test
void shouldFindActivitiesByLocationId() {
// Given
Location anotherLocation = new Location();
anotherLocation.setName("Another Location");
anotherLocation.setCity("Another City");
anotherLocation = entityManager.persistAndFlush(anotherLocation);
Activity activityAtTestLocation = new Activity();
activityAtTestLocation.setName("Activity at Test Location");
activityAtTestLocation.setLocation(testLocation);
activityAtTestLocation.setPriceRange(2);
Activity activityAtAnotherLocation = new Activity();
activityAtAnotherLocation.setName("Activity at Another Location");
activityAtAnotherLocation.setLocation(anotherLocation);
activityAtAnotherLocation.setPriceRange(3);
activityRepository.saveAll(Arrays.asList(activityAtTestLocation, activityAtAnotherLocation));
entityManager.flush();
// When
List<Activity> activitiesAtTestLocation = activityRepository.findByLocationId(testLocation.getId());
List<Activity> activitiesAtAnotherLocation = activityRepository.findByLocationId(anotherLocation.getId());
// Then
assertThat(activitiesAtTestLocation).hasSize(1);
assertThat(activitiesAtTestLocation.get(0).getName()).isEqualTo("Activity at Test Location");
assertThat(activitiesAtAnotherLocation).hasSize(1);
assertThat(activitiesAtAnotherLocation.get(0).getName()).isEqualTo("Activity at Another Location");
}
@Test
void shouldFindActivitiesByTagsContaining() {
// Given
Activity outdoorActivity = new Activity();
outdoorActivity.setName("Rock Climbing");
outdoorActivity.setLocation(testLocation);
outdoorActivity.setPriceRange(3);
outdoorActivity.setTags(Arrays.asList("outdoor", "adventure", "climbing"));
Activity indoorActivity = new Activity();
indoorActivity.setName("Museum Visit");
indoorActivity.setLocation(testLocation);
indoorActivity.setPriceRange(2);
indoorActivity.setTags(Arrays.asList("indoor", "culture", "educational"));
activityRepository.saveAll(Arrays.asList(outdoorActivity, indoorActivity));
entityManager.flush();
// When
List<Activity> outdoorActivities = activityRepository.findByTagsContaining("outdoor");
List<Activity> cultureActivities = activityRepository.findByTagsContaining("culture");
// Then
assertThat(outdoorActivities).hasSize(1);
assertThat(outdoorActivities.get(0).getName()).isEqualTo("Rock Climbing");
assertThat(cultureActivities).hasSize(1);
assertThat(cultureActivities.get(0).getName()).isEqualTo("Museum Visit");
}
@Test
void shouldFindActivitiesByPriceRangeLessThanEqual() {
// Given
Activity cheapActivity = new Activity();
cheapActivity.setName("Budget Activity");
cheapActivity.setLocation(testLocation);
cheapActivity.setPriceRange(1);
Activity moderateActivity = new Activity();
moderateActivity.setName("Moderate Activity");
moderateActivity.setLocation(testLocation);
moderateActivity.setPriceRange(3);
Activity expensiveActivity = new Activity();
expensiveActivity.setName("Expensive Activity");
expensiveActivity.setLocation(testLocation);
expensiveActivity.setPriceRange(5);
activityRepository.saveAll(Arrays.asList(cheapActivity, moderateActivity, expensiveActivity));
entityManager.flush();
// When
List<Activity> budgetActivities = activityRepository.findByPriceRangeLessThanEqual(2);
List<Activity> moderateBudgetActivities = activityRepository.findByPriceRangeLessThanEqual(3);
// Then
assertThat(budgetActivities).hasSize(1);
assertThat(budgetActivities.get(0).getName()).isEqualTo("Budget Activity");
assertThat(moderateBudgetActivities).hasSize(2);
assertThat(moderateBudgetActivities)
.extracting(Activity::getName)
.containsExactlyInAnyOrder("Budget Activity", "Moderate Activity");
}
@Test
void shouldUpdateActivityTimestamps() {
// Given
Activity savedActivity = activityRepository.save(testActivity);
entityManager.flush();
entityManager.clear();
// When
Optional<Activity> retrievedActivity = activityRepository.findById(savedActivity.getId());
assertThat(retrievedActivity).isPresent();
Activity activityToUpdate = retrievedActivity.get();
activityToUpdate.setName("Updated Activity Name");
Activity updatedActivity = activityRepository.save(activityToUpdate);
entityManager.flush();
// Then
assertThat(updatedActivity.getCreatedAt()).isNotNull();
assertThat(updatedActivity.getUpdatedAt()).isNotNull();
assertThat(updatedActivity.getUpdatedAt()).isAfterOrEqualTo(updatedActivity.getCreatedAt());
}
@Test
void shouldDeleteActivity() {
// Given
Activity savedActivity = activityRepository.save(testActivity);
entityManager.flush();
Long activityId = savedActivity.getId();
// When
activityRepository.deleteById(activityId);
entityManager.flush();
// Then
Optional<Activity> deletedActivity = activityRepository.findById(activityId);
assertThat(deletedActivity).isEmpty();
}
}

View File

@@ -0,0 +1,46 @@
package com.vibing.backend;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import java.time.LocalDateTime;
import java.util.Random;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import com.vibing.backend.model.Activity;
import com.vibing.backend.model.Location;
import com.vibing.backend.repository.ActivityRepository;
import com.vibing.backend.service.ActivityService;
import net.datafaker.Faker;
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(locations = "classpath:application-test.yml")
public class ActivityTestIt {
private Faker faker = new Faker(new Random(123));
@Autowired
private ActivityService activityService;
@Autowired
private ActivityRepository activityRepository;
@Test
void shouldSaveActivity() {
Activity activity = new Activity();
activity.setName(faker.location().publicSpace());
activity.setPriceRange(faker.number().numberBetween(1, 5));
activity.setTags(faker.lorem().words(3));
activity.setLocation(new Location().withName(faker.location().publicSpace()));
activityRepository.save(activity);
assertThat(activityService.findAll(), containsInAnyOrder(activity));
}
}

View File

@@ -0,0 +1,18 @@
package com.vibing.backend;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* Basic test class for the Vibing Backend application.
*/
@SpringBootTest
@ActiveProfiles("test")
class VibingBackendApplicationTests {
@Test
void contextLoads() {
// This test verifies that the Spring application context loads successfully
}
}

View File

@@ -0,0 +1,25 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL;INIT=CREATE SCHEMA IF NOT EXISTS vibing
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: none # Let Flyway handle schema creation
show-sql: true
database-platform: org.hibernate.dialect.H2Dialect
properties:
hibernate:
default_schema: vibing
flyway:
enabled: true
clean-disabled: false # Allow clean in tests
locations: classpath:db/migration
default-schema: vibing

View File

@@ -1,6 +1,6 @@
import React from 'react';
import './Footer.css';
import { useLanguage } from '../contexts/LanguageContext';
import { useLanguage } from '../hooks/useLanguage';
const Footer = () => {
const { t } = useLanguage();

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useLanguage } from '../hooks/useLanguage';
import LanguageSwitcher from './LanguageSwitcher';
import './Header.css';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { useLanguage } from '../hooks/useLanguage';
import './LanguageSwitcher.css';
const LanguageSwitcher = () => {

View File

@@ -1,86 +1,5 @@
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ä!',
}
};
import React, { useState } from 'react';
import { LanguageContext, translations } from './languageContextData';
export const LanguageProvider = ({ children }) => {
const [language, setLanguage] = useState('en');

View File

@@ -0,0 +1,75 @@
import { createContext } from 'react';
export const LanguageContext = createContext();
export 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ä!',
}
};

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { LanguageContext } from '../contexts/languageContextData';
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};

View File

@@ -205,6 +205,107 @@
gap: 2rem;
}
/* Search Results Section */
.search-results {
padding: 4rem 0;
background: white;
}
.results-title {
text-align: center;
font-size: 2rem;
font-weight: 700;
margin: 0 0 3rem 0;
color: var(--color-text-primary);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.result-card {
height: 100%;
display: flex;
flex-direction: column;
}
.result-card .card-content {
flex: 1;
display: flex;
flex-direction: column;
}
.result-card .card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.result-card .card-body p {
margin: 0;
line-height: 1.6;
color: var(--color-text-secondary);
}
.amenities {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
}
.amenity-tag {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: var(--color-dark);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
box-shadow: 0 2px 8px var(--color-shadow-primary);
transition: all 0.3s ease;
}
.amenity-tag:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--color-shadow-primary);
}
/* No Results Section */
.no-results {
padding: 4rem 0;
background: white;
text-align: center;
}
.no-results-content h2 {
font-size: 2rem;
font-weight: 700;
margin: 0 0 1rem 0;
color: var(--color-text-primary);
}
.no-results-content p {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin: 0;
}
/* Loading State */
.search-button.searching {
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, var(--color-dark) 100%);
cursor: not-allowed;
opacity: 0.8;
}
.search-button.searching:hover {
transform: none;
box-shadow: 0 4px 12px var(--color-shadow-secondary);
}
/* Responsive Design */
@media (max-width: 768px) {
.hero {
@@ -261,6 +362,27 @@
.features-grid {
grid-template-columns: 1fr;
}
.search-results {
padding: 3rem 0;
}
.results-title {
font-size: 1.75rem;
}
.results-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.no-results {
padding: 3rem 0;
}
.no-results-content h2 {
font-size: 1.75rem;
}
}
@media (max-width: 480px) {
@@ -284,6 +406,14 @@
min-width: 180px;
padding: 0.75rem;
}
.results-title {
font-size: 1.5rem;
}
.no-results-content h2 {
font-size: 1.5rem;
}
}
/* Dark Mode Support */
@@ -334,4 +464,24 @@
.features {
background: var(--color-bg-dark);
}
.search-results {
background: var(--color-bg-dark);
}
.no-results {
background: var(--color-bg-dark);
}
.results-title {
color: var(--color-text-primary-light);
}
.no-results-content h2 {
color: var(--color-text-primary-light);
}
.no-results-content p {
color: var(--color-text-secondary);
}
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Header, Footer, Button, Card } from '../components';
import { useLanguage } from '../contexts/LanguageContext';
import { useLanguage } from '../hooks/useLanguage';
import './Home.css';
const Home = () => {
@@ -13,14 +13,62 @@ const Home = () => {
peopleCount: '',
priceRange: ''
});
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// Mockup data for search results
const mockResults = [
{
id: 1,
title: "Cozy Mountain Cabin",
subtitle: "€€ • 4 guests • Dog friendly",
image: "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Beautiful mountain cabin with stunning views, perfect for a peaceful getaway. Features a hot tub and fireplace.",
amenities: ["Dog friendly", "Fireplace", "Hot tub", "Mountain view"]
},
{
id: 2,
title: "Modern City Apartment",
subtitle: "€€€ • 2 guests • Accessible",
image: "https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Stylish apartment in the heart of the city with modern amenities and easy access to public transportation.",
amenities: ["Accessible", "City view", "Gym access", "Parking"]
},
{
id: 3,
title: "Family Beach House",
subtitle: "€€€€ • 6 guests • Family friendly",
image: "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Spacious beachfront property perfect for family vacations. Includes a pool, playground, and direct beach access.",
amenities: ["Family friendly", "Beach access", "Pool", "Playground"]
},
{
id: 4,
title: "Rustic Farmhouse",
subtitle: "€€ • 8 guests • Dog friendly",
image: "https://images.unsplash.com/photo-1571896349842-33c89424de2d?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Charming farmhouse surrounded by nature. Perfect for large groups looking for a rural escape.",
amenities: ["Dog friendly", "Large groups", "Nature", "Fireplace"]
},
{
id: 5,
title: "Luxury Villa",
subtitle: "€€€€ • 4 guests • Accessible",
image: "https://images.unsplash.com/photo-1613490493576-7fde63acd811?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Exclusive villa with premium amenities including a private pool, chef's kitchen, and stunning ocean views.",
amenities: ["Accessible", "Private pool", "Ocean view", "Chef's kitchen"]
},
{
id: 6,
title: "Cozy Studio",
subtitle: "€ • 2 guests • Family friendly",
image: "https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?q=80&w=1920&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
description: "Affordable studio apartment perfect for couples or small families. Located in a quiet neighborhood.",
amenities: ["Family friendly", "Affordable", "Quiet", "Central location"]
}
];
const handleGetStarted = () => {
alert('Get Started button clicked!');
};
const handleLearnMore = () => {
alert('Learn More button clicked!');
};
const handleFilterChange = (filterName, value) => {
setFilters(prev => ({
@@ -30,9 +78,30 @@ const Home = () => {
};
const handleSearch = () => {
// Handle search logic here
console.log('Search query:', searchQuery);
console.log('Filters:', filters);
if (!searchQuery.trim()) {
alert('Please enter a search query');
return;
}
setIsSearching(true);
// Simulate API call delay
setTimeout(() => {
// Filter results based on search query and filters
let filteredResults = mockResults.filter(result => {
const matchesQuery = result.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
result.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesDogFriendly = !filters.dogFriendly || result.amenities.includes('Dog friendly');
const matchesAccessible = !filters.accessible || result.amenities.includes('Accessible');
const matchesFamilyFriendly = !filters.familyFriendly || result.amenities.includes('Family friendly');
return matchesQuery && matchesDogFriendly && matchesAccessible && matchesFamilyFriendly;
});
setSearchResults(filteredResults);
setIsSearching(false);
}, 1000);
};
return (
@@ -57,8 +126,12 @@ const Home = () => {
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
<button className="search-button" onClick={handleSearch}>
{t('searchButton')}
<button
className={`search-button ${isSearching ? 'searching' : ''}`}
onClick={handleSearch}
disabled={isSearching}
>
{isSearching ? 'Searching...' : t('searchButton')}
</button>
</div>
@@ -140,6 +213,50 @@ const Home = () => {
</div>
</section>
{/* Search Results Section */}
{searchResults.length > 0 && (
<section className="search-results">
<div className="container">
<h2 className="results-title">
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for "{searchQuery}"
</h2>
<div className="results-grid">
{searchResults.map(result => (
<Card
key={result.id}
title={result.title}
subtitle={result.subtitle}
image={result.image}
imageAlt={result.title}
className="result-card"
>
<p>{result.description}</p>
<div className="amenities">
{result.amenities.map((amenity, index) => (
<span key={index} className="amenity-tag">
{amenity}
</span>
))}
</div>
</Card>
))}
</div>
</div>
</section>
)}
{/* No Results Message */}
{searchResults.length === 0 && !isSearching && searchQuery && (
<section className="no-results">
<div className="container">
<div className="no-results-content">
<h2>No results found for "{searchQuery}"</h2>
<p>Try adjusting your search terms or filters to find more options.</p>
</div>
</div>
</section>
)}
</main>
<Footer />

11
local-dev/compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
postgres:
image: postgres:15
ports:
- 5432:5432
environment:
POSTGRES_USER: vibing
POSTGRES_PASSWORD: vibing
POSTGRES_DB: vibing
volumes:
- postgres_data:/var/lib/postgresql/data