mirror of
https://github.com/serialexperiments0815/Dilomarkt---Digitaler-Lokalmarkt.git
synced 2026-07-12 15:32:19 +00:00
Views, api uind readme
This commit is contained in:
parent
d534ca87a1
commit
caf416ec32
8 changed files with 572 additions and 0 deletions
71
Dilomarkt/README.md
Normal file
71
Dilomarkt/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Dilomarkt – Digitaler Lokalmarkt
|
||||
|
||||
Dilomarkt ist ein spezialisierter Marktplatz, der lokale Anbieter von Baumaterialien und Werkzeugen mit regionalen Nachfragern verbindet. Entwickelt von Studenten, adressiert die Plattform die Ineffizienzen großer, unspezialisierter Portale und stärkt lokale Wertschöpfungsketten.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Server:** PHP mit Laravel-Framework
|
||||
- **Datenbank:** SQLite
|
||||
- **Client:** Vue.js (HTML, CSS, JavaScript)
|
||||
- **Client-Server-Kommunikation:** REST API
|
||||
- **Weiteres:** Responsive Webdesign
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/serialexperiments0815/Dilomarkt---Digitaler-Lokalmarkt/
|
||||
```
|
||||
|
||||
### Backend (Laravel)
|
||||
|
||||
```bash
|
||||
cd Dilomarkt---Digitaler-Lokalmarkt/Dilomarkt/dilomarkt-api
|
||||
composer install
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
php artisan migrate
|
||||
php artisan db:seed
|
||||
```
|
||||
|
||||
### Frontend (Vue)
|
||||
|
||||
```bash
|
||||
cd Dilomarkt---Digitaler-Lokalmarkt/Dilomarkt
|
||||
npm install
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
**Terminal 1 – Backend starten:**
|
||||
```bash
|
||||
cd dilomarkt-api
|
||||
php artisan serve
|
||||
```
|
||||
API läuft auf `http://localhost:8000`
|
||||
|
||||
**Terminal 2 – Frontend starten:**
|
||||
```bash
|
||||
cd Dilomarkt
|
||||
npm run dev
|
||||
```
|
||||
App läuft auf `http://localhost:5173`
|
||||
|
||||
## API Endpunkte
|
||||
|
||||
| Method | Endpunkt | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/api/products` | Produkte mit Geo-Filter (PLZ, Radius, Kategorie) |
|
||||
| GET | `/api/products/{id}` | Einzelprodukt mit Preisvergleich |
|
||||
| GET | `/api/providers` | Alle Anbieter |
|
||||
| GET | `/api/providers/{id}` | Anbieterprofil mit Statistiken |
|
||||
|
||||
### Query Parameter für `/api/products`
|
||||
|
||||
| Parameter | Beispiel | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `plz` | `42103` | Standort (NRW) |
|
||||
| `radius` | `20` | Umkreis in km |
|
||||
| `q` | `Klinker` | Suchbegriff |
|
||||
| `category` | `Werkzeug` | Kategorie |
|
||||
| `max_price` | `100` | Maximaler Preis |
|
||||
| `type` | `Fachhandel` | Anbietertyp |
|
||||
|
|
@ -7,3 +7,74 @@
|
|||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark-card: #1e1e1e;
|
||||
--bg-app-dark: #121212;
|
||||
--text-main: #e0e0e0;
|
||||
--primary-accent: #2196f3;
|
||||
--border-color: #333333;
|
||||
}
|
||||
body { margin: 0; background-color: var(--bg-app-dark); color: var(--text-main); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.view-container { max-width: 1200px; margin: 0 auto; padding: 20px; padding-bottom: 90px; }
|
||||
.hero-section { text-align: center; padding: 20px 10px; }
|
||||
.search-container { display: flex; gap: 10px; max-width: 700px; margin: 20px auto; background: var(--bg-dark-card); padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); }
|
||||
.search-container input { flex: 2; background: transparent; border: none; color: white; padding: 10px; font-size: 14px; }
|
||||
.search-container input.location-input { flex: 1; border-left: 1px solid var(--border-color); }
|
||||
.search-container select { background: #2a2a2a; border: none; border-left: 1px solid var(--border-color); color: var(--text-main); padding: 10px; cursor: pointer; }
|
||||
.marketplace-body { display: flex; gap: 20px; }
|
||||
.filter-sidebar { width: 220px; flex-shrink: 0; background: var(--bg-dark-card); padding: 20px; border-radius: 8px; height: fit-content; }
|
||||
.filter-sidebar h3 { font-size: 11px; color: #888; letter-spacing: 1px; margin: 16px 0 8px; }
|
||||
.filter-sidebar label { display: block; margin: 8px 0; font-size: 14px; cursor: pointer; }
|
||||
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
|
||||
.product-card { background: var(--bg-dark-card); border-radius: 8px; overflow: hidden; border: 1px solid var(--border-color); cursor: pointer; transition: border-color 0.2s; }
|
||||
.product-card:hover { border-color: var(--primary-accent); }
|
||||
.card-image-placeholder { background: #2a2a2a; height: 120px; display: flex; align-items: center; justify-content: center; font-size: 40px; }
|
||||
.card-details { padding: 15px; }
|
||||
.price { font-size: 20px; font-weight: bold; color: var(--primary-accent); }
|
||||
.card-footer { display: flex; justify-content: space-between; font-size: 12px; color: #777; margin-top: 10px; }
|
||||
.badge { background: #2e7d32; color: white; padding: 2px 6px; border-radius: 4px; }
|
||||
.badge-cat { background: #37474f; color: #ccc; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
||||
.back-link { background: transparent; border: none; color: var(--primary-accent); cursor: pointer; margin-bottom: 20px; font-size: 14px; padding: 0; }
|
||||
.detail-layout { display: flex; gap: 30px; margin-top: 20px; }
|
||||
.detail-main { flex: 2; }
|
||||
.detail-sidebar { flex: 1; }
|
||||
.large-image-placeholder { background: #2a2a2a; height: 250px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 60px; }
|
||||
.detail-price { font-size: 28px; font-weight: bold; color: var(--primary-accent); }
|
||||
.seller-card { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 20px; }
|
||||
.seller-avatar, .avatar-large { width: 50px; height: 50px; border-radius: 50%; background: var(--primary-accent); display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; }
|
||||
.avatar-large { width: 70px; height: 70px; font-size: 22px; flex-shrink: 0; }
|
||||
.btn-primary, .btn-action { background: var(--primary-accent); color: white; border: none; padding: 12px; border-radius: 6px; font-weight: bold; cursor: pointer; }
|
||||
.btn-action { width: 100%; margin-top: 10px; }
|
||||
.comparison-box { margin-top: 20px; background: var(--bg-dark-card); padding: 15px; border-radius: 8px; }
|
||||
.comparison-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border-color); }
|
||||
.comparison-row.active { color: var(--primary-accent); font-weight: bold; }
|
||||
.profile-header-card { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 20px; display: flex; gap: 20px; align-items: center; }
|
||||
.profile-stats { display: flex; gap: 20px; margin-top: 20px; }
|
||||
.stat-box { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; text-align: center; flex: 1; }
|
||||
.stat-box .num { font-size: 28px; font-weight: bold; color: var(--primary-accent); }
|
||||
.stat-box .label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.category-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
|
||||
.chip { background: #2a2a2a; border: 1px solid var(--border-color); border-radius: 20px; padding: 6px 14px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
|
||||
.chip:hover, .chip.active { background: var(--primary-accent); border-color: var(--primary-accent); color: white; }
|
||||
.mobile-bottom-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 60px; background: #1a1a1a; border-top: 1px solid var(--border-color); display: flex; justify-content: space-around; align-items: center; z-index: 100; }
|
||||
.mobile-bottom-nav button { background: transparent; border: none; color: #888; font-size: 12px; cursor: pointer; }
|
||||
.mobile-bottom-nav button.active { color: var(--primary-accent); }
|
||||
.feed-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.tag { font-size: 11px; color: #aaa; }
|
||||
@media (max-width: 768px) {
|
||||
.filter-sidebar { display: none; }
|
||||
.search-container { flex-direction: column; }
|
||||
.search-container input.location-input, .search-container select { border-left: none; border-top: 1px solid var(--border-color); }
|
||||
.detail-layout { flex-direction: column; }
|
||||
.products-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
||||
.profile-stats { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
26
Dilomarkt/src/api.js
Normal file
26
Dilomarkt/src/api.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const BASE = 'http://localhost:8000/api'
|
||||
|
||||
export async function fetchProducts(params = {}) {
|
||||
const q = new URLSearchParams(params).toString()
|
||||
const res = await fetch(`${BASE}/products?${q}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Produkte')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchProduct(id) {
|
||||
const res = await fetch(`${BASE}/products/${id}`)
|
||||
if (!res.ok) throw new Error('Produkt nicht gefunden')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchProviders() {
|
||||
const res = await fetch(`${BASE}/providers`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Anbieter')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchProvider(id) {
|
||||
const res = await fetch(`${BASE}/providers/${id}`)
|
||||
if (!res.ok) throw new Error('Anbieter nicht gefunden')
|
||||
return res.json()
|
||||
}
|
||||
94
Dilomarkt/src/views/HomeView.vue
Normal file
94
Dilomarkt/src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="view-container">
|
||||
<header class="hero-section">
|
||||
<h1>Lokale Bau- und Heimwerkermärkte in deiner Nähe</h1>
|
||||
<p>Finde Materialien, Werkzeug und Restposten von regionalen Anbietern – direkt zum Abholen.</p>
|
||||
<div class="search-container">
|
||||
<input v-model="q" @keyup.enter="doSearch" placeholder="z. B. Klinker, Bohrmaschine..." />
|
||||
<input v-model="plz" class="location-input" placeholder="PLZ (z.B. 42103)" />
|
||||
<select v-model="cat">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option v-for="c in CATEGORIES" :key="c">{{ c }}</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="doSearch">Suchen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="category-chips">
|
||||
<span v-for="c in CATEGORIES" :key="c" class="chip" @click="goCategory(c)">{{ categoryIcon(c) }} {{ c }}</span>
|
||||
</div>
|
||||
|
||||
<div class="marketplace-body">
|
||||
<aside class="filter-sidebar">
|
||||
<h3>UMKREIS</h3>
|
||||
<label v-for="r in [5,10,20]" :key="r"><input type="radio" v-model="radius" :value="r" /> {{ r }} km</label>
|
||||
<h3>ANBIETERTYP</h3>
|
||||
<label><input type="checkbox" v-model="types" value="Fachhandel" /> Fachhandel</label>
|
||||
<label><input type="checkbox" v-model="types" value="Baumarkt" /> Baumarkt</label>
|
||||
</aside>
|
||||
<main style="flex:1">
|
||||
<div class="feed-header"><strong>{{ products.length }} Angebote</strong></div>
|
||||
<div v-if="loading" style="color:#aaa;padding:20px">Lädt...</div>
|
||||
<div v-if="error" style="color:#f44336;padding:20px">{{ error }}</div>
|
||||
<div class="products-grid">
|
||||
<div v-for="p in products" :key="p.id" class="product-card" @click="router.push(`/produkt/${p.id}`)">
|
||||
<div class="card-image-placeholder"><span>{{ p.icon }}</span></div>
|
||||
<div class="card-details">
|
||||
<span class="price">{{ p.price }} €</span>
|
||||
<h4 style="margin:6px 0 4px">{{ p.title }}</h4>
|
||||
<p style="margin:0;font-size:12px;color:#aaa">{{ p.provider }}</p>
|
||||
<div class="card-footer">
|
||||
<span>📍 {{ p.distance }} km</span>
|
||||
<span class="badge-cat">{{ p.category }}</span>
|
||||
<span class="badge">Abholung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchProducts } from '@/api.js'
|
||||
|
||||
const CATEGORIES = ['Baumaterial','Werkzeug','Holz & Platten','Farben & Lacke','Sanitär','Elektro']
|
||||
const router = useRouter()
|
||||
const q = ref('')
|
||||
const plz = ref('42103')
|
||||
const cat = ref('')
|
||||
const radius = ref(10)
|
||||
const types = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
products.value = await fetchProducts({ plz: plz.value, radius: radius.value, q: q.value, category: cat.value })
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([radius, cat], load)
|
||||
onMounted(load)
|
||||
|
||||
function doSearch() {
|
||||
router.push({ path: '/suche', query: { q: q.value, cat: cat.value, plz: plz.value } })
|
||||
}
|
||||
function goCategory(c) {
|
||||
router.push({ path: '/suche', query: { cat: c } })
|
||||
}
|
||||
function categoryIcon(c) {
|
||||
const m = { 'Baumaterial':'🧱','Werkzeug':'🔧','Holz & Platten':'🪵','Farben & Lacke':'💧','Sanitär':'🚿','Elektro':'💡' }
|
||||
return m[c] ?? '📦'
|
||||
}
|
||||
</script>
|
||||
70
Dilomarkt/src/views/ProductView.vue
Normal file
70
Dilomarkt/src/views/ProductView.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="view-container" v-if="product">
|
||||
<button class="back-link" @click="router.back()">← Zurück</button>
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<div class="large-image-placeholder"><span>{{ product.icon }}</span></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:12px">
|
||||
<span class="badge-cat">{{ product.category }}</span>
|
||||
<span style="font-size:13px;color:#888">Lagerbestand: {{ product.stock }} Stück</span>
|
||||
</div>
|
||||
<h2 style="margin:10px 0 4px">{{ product.title }}</h2>
|
||||
<p class="detail-price">{{ product.price }} €</p>
|
||||
<p style="color:#aaa;line-height:1.6">{{ product.description }}</p>
|
||||
<div class="comparison-box">
|
||||
<h3 style="margin-top:0">Preisvergleich</h3>
|
||||
<div class="comparison-row active">
|
||||
<span>{{ product.provider }}</span><strong>{{ product.price }} €</strong>
|
||||
</div>
|
||||
<div class="comparison-row" v-for="a in alternatives" :key="a.id">
|
||||
<span style="cursor:pointer;color:var(--primary-accent)" @click="router.push(`/produkt/${a.id}`)">{{ a.provider }}</span>
|
||||
<strong>{{ a.price }} €</strong>
|
||||
</div>
|
||||
<div v-if="!alternatives.length" style="color:#666;font-size:13px;padding-top:8px">Keine weiteren Angebote.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-sidebar">
|
||||
<div class="seller-card" v-if="product">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
<div class="seller-avatar">{{ product.initials }}</div>
|
||||
<div>
|
||||
<h3 style="margin:0;cursor:pointer;color:var(--primary-accent)" @click="router.push(`/anbieter/${product.provider_id}`)">{{ product.provider }}</h3>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#aaa">{{ product.provider_type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#aaa">📦 {{ product.address }}, {{ product.city }}</p>
|
||||
<div v-if="product.verified" style="font-size:12px;color:#4caf50;margin:8px 0">✅ Verifizierter Anbieter</div>
|
||||
<button class="btn-action" @click="alert('Anfrage gesendet!')">✉️ Abholung anfragen</button>
|
||||
<button class="btn-action" style="background:#333;margin-top:8px" @click="router.push(`/anbieter/${product.provider_id}`)">Alle Angebote ansehen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="view-container" style="color:#aaa">Lädt...</div>
|
||||
<div v-else class="view-container" style="color:#f44336">{{ error || 'Produkt nicht gefunden.' }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchProduct } from '@/api.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const product = ref(null)
|
||||
const alternatives = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await fetchProduct(route.params.id)
|
||||
product.value = data.product
|
||||
alternatives.value = data.alternatives
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
71
Dilomarkt/src/views/ProviderView.vue
Normal file
71
Dilomarkt/src/views/ProviderView.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="view-container" v-if="provider">
|
||||
<button class="back-link" @click="router.back()">← Zurück</button>
|
||||
<div class="profile-header-card">
|
||||
<div class="avatar-large">{{ provider.initials }}</div>
|
||||
<div>
|
||||
<h2 style="margin:0">{{ provider.name }}</h2>
|
||||
<p style="margin:4px 0;color:#aaa;font-size:14px">{{ provider.address }}, {{ provider.zip }} {{ provider.city }}</p>
|
||||
<p style="margin:0;font-size:13px;color:#888">{{ provider.type }} · seit {{ provider.since }}</p>
|
||||
<span v-if="provider.verified" style="font-size:12px;color:#4caf50">✅ Verifiziert</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-stats">
|
||||
<div class="stat-box"><div class="num">{{ stats.product_count }}</div><div class="label">Aktive Angebote</div></div>
|
||||
<div class="stat-box"><div class="num">{{ stats.total_stock }}</div><div class="label">Einheiten gesamt</div></div>
|
||||
<div class="stat-box"><div class="num">{{ stats.min_price }} €</div><div class="label">Ab Preis</div></div>
|
||||
</div>
|
||||
<h3 style="margin-top:28px">Kategorien</h3>
|
||||
<div class="category-chips">
|
||||
<span :class="['chip', activeCat==='' && 'active']" @click="activeCat=''">Alle</span>
|
||||
<span v-for="c in categories" :key="c" :class="['chip', activeCat===c && 'active']" @click="activeCat=c">{{ c }}</span>
|
||||
</div>
|
||||
<h3>Angebote</h3>
|
||||
<div class="products-grid">
|
||||
<div v-for="p in filteredProducts" :key="p.id" class="product-card" @click="router.push(`/produkt/${p.id}`)">
|
||||
<div class="card-image-placeholder"><span>{{ p.icon }}</span></div>
|
||||
<div class="card-details">
|
||||
<span class="price">{{ p.price }} €</span>
|
||||
<h4 style="margin:6px 0 4px">{{ p.title }}</h4>
|
||||
<div class="card-footer">
|
||||
<span class="badge-cat">{{ p.category }}</span>
|
||||
<span style="font-size:12px;color:#888">{{ p.stock }} Stk.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="view-container" style="color:#aaa">Lädt...</div>
|
||||
<div v-else class="view-container" style="color:#f44336">{{ error }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchProvider } from '@/api.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const provider = ref(null)
|
||||
const products = ref([])
|
||||
const stats = ref({})
|
||||
const activeCat = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const categories = computed(() => [...new Set(products.value.map(p => p.category))])
|
||||
const filteredProducts = computed(() => activeCat.value ? products.value.filter(p => p.category === activeCat.value) : products.value)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await fetchProvider(route.params.id)
|
||||
provider.value = data.provider
|
||||
products.value = data.products
|
||||
stats.value = data.stats
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
98
Dilomarkt/src/views/SearchView.vue
Normal file
98
Dilomarkt/src/views/SearchView.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="view-container">
|
||||
<div class="search-container" style="max-width:100%;margin-bottom:16px">
|
||||
<input v-model="q" @keyup.enter="load" placeholder="Suchen..." />
|
||||
<select v-model="cat" @change="load">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option v-for="c in CATEGORIES" :key="c">{{ c }}</option>
|
||||
</select>
|
||||
<input v-model="plz" class="location-input" placeholder="PLZ" @change="load" />
|
||||
<button class="btn-primary" @click="load">Suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="category-chips">
|
||||
<span :class="['chip', !cat && 'active']" @click="cat='';load()">Alle</span>
|
||||
<span v-for="c in CATEGORIES" :key="c" :class="['chip', cat===c && 'active']" @click="cat=c;load()">{{ c }}</span>
|
||||
</div>
|
||||
|
||||
<div class="marketplace-body">
|
||||
<aside class="filter-sidebar">
|
||||
<h3>UMKREIS</h3>
|
||||
<label v-for="r in [5,10,20,50]" :key="r"><input type="radio" v-model="radius" :value="r" @change="load" /> {{ r }} km</label>
|
||||
<h3>ANBIETERTYP</h3>
|
||||
<label><input type="checkbox" v-model="types" value="Fachhandel" @change="load" /> Fachhandel</label>
|
||||
<label><input type="checkbox" v-model="types" value="Baumarkt" @change="load" /> Baumarkt</label>
|
||||
<h3>PREIS BIS</h3>
|
||||
<input type="range" v-model="maxPrice" min="1" max="500" style="width:100%" @change="load" />
|
||||
<span style="font-size:13px">max. {{ maxPrice }} €</span>
|
||||
</aside>
|
||||
<main style="flex:1">
|
||||
<div class="feed-header">
|
||||
<strong>{{ products.length }} Ergebnisse</strong>
|
||||
<span class="tag" v-if="q"> für „{{ q }}"</span>
|
||||
<span class="tag"> · {{ radius }} km um {{ plz }}</span>
|
||||
</div>
|
||||
<div v-if="loading" style="color:#aaa;padding:20px">Lädt...</div>
|
||||
<div v-if="error" style="color:#f44336;padding:20px">{{ error }}</div>
|
||||
<div v-if="!loading && products.length === 0" style="color:#888;margin-top:40px;text-align:center">Keine Ergebnisse gefunden.</div>
|
||||
<div class="products-grid">
|
||||
<div v-for="p in products" :key="p.id" class="product-card" @click="router.push(`/produkt/${p.id}`)">
|
||||
<div class="card-image-placeholder"><span>{{ p.icon }}</span></div>
|
||||
<div class="card-details">
|
||||
<span class="price">{{ p.price }} €</span>
|
||||
<h4 style="margin:6px 0 4px">{{ p.title }}</h4>
|
||||
<p style="margin:0;font-size:12px;color:#aaa">{{ p.provider }}</p>
|
||||
<div class="card-footer">
|
||||
<span>📍 {{ p.distance }} km</span>
|
||||
<span class="badge-cat">{{ p.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchProducts } from '@/api.js'
|
||||
|
||||
const CATEGORIES = ['Baumaterial','Werkzeug','Holz & Platten','Farben & Lacke','Sanitär','Elektro']
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const q = ref(route.query.q ?? '')
|
||||
const cat = ref(route.query.cat ?? '')
|
||||
const plz = ref(route.query.plz ?? '42103')
|
||||
const radius = ref(20)
|
||||
const maxPrice = ref(500)
|
||||
const types = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = { plz: plz.value, radius: radius.value, q: q.value, category: cat.value, max_price: maxPrice.value }
|
||||
if (types.value.length === 1) params.type = types.value[0]
|
||||
products.value = await fetchProducts(params)
|
||||
router.replace({ query: { q: q.value, cat: cat.value, plz: plz.value } })
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query, q => {
|
||||
q.value = route.query.q ?? ''
|
||||
cat.value = route.query.cat ?? ''
|
||||
load()
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
71
README.md
Normal file
71
README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Dilomarkt – Digitaler Lokalmarkt
|
||||
|
||||
Dilomarkt ist ein spezialisierter Marktplatz, der lokale Anbieter von Baumaterialien und Werkzeugen mit regionalen Nachfragern verbindet. Entwickelt von Studenten, adressiert die Plattform die Ineffizienzen großer, unspezialisierter Portale und stärkt lokale Wertschöpfungsketten.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Server:** PHP mit Laravel-Framework
|
||||
- **Datenbank:** SQLite
|
||||
- **Client:** Vue.js (HTML, CSS, JavaScript)
|
||||
- **Client-Server-Kommunikation:** REST API
|
||||
- **Weiteres:** Responsive Webdesign
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/serialexperiments0815/Dilomarkt---Digitaler-Lokalmarkt/
|
||||
```
|
||||
|
||||
### Backend (Laravel)
|
||||
|
||||
```bash
|
||||
cd Dilomarkt---Digitaler-Lokalmarkt/Dilomarkt/dilomarkt-api
|
||||
composer install
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
php artisan migrate
|
||||
php artisan db:seed
|
||||
```
|
||||
|
||||
### Frontend (Vue)
|
||||
|
||||
```bash
|
||||
cd Dilomarkt---Digitaler-Lokalmarkt/Dilomarkt
|
||||
npm install
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
**Terminal 1 – Backend starten:**
|
||||
```bash
|
||||
cd dilomarkt-api
|
||||
php artisan serve
|
||||
```
|
||||
API läuft auf `http://localhost:8000`
|
||||
|
||||
**Terminal 2 – Frontend starten:**
|
||||
```bash
|
||||
cd Dilomarkt
|
||||
npm run dev
|
||||
```
|
||||
App läuft auf `http://localhost:5173`
|
||||
|
||||
## API Endpunkte
|
||||
|
||||
| Method | Endpunkt | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/api/products` | Produkte mit Geo-Filter (PLZ, Radius, Kategorie) |
|
||||
| GET | `/api/products/{id}` | Einzelprodukt mit Preisvergleich |
|
||||
| GET | `/api/providers` | Alle Anbieter |
|
||||
| GET | `/api/providers/{id}` | Anbieterprofil mit Statistiken |
|
||||
|
||||
### Query Parameter für `/api/products`
|
||||
|
||||
| Parameter | Beispiel | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `plz` | `42103` | Standort (NRW) |
|
||||
| `radius` | `20` | Umkreis in km |
|
||||
| `q` | `Klinker` | Suchbegriff |
|
||||
| `category` | `Werkzeug` | Kategorie |
|
||||
| `max_price` | `100` | Maximaler Preis |
|
||||
| `type` | `Fachhandel` | Anbietertyp |
|
||||
Loading…
Reference in a new issue