feat(market): admin edit link and public feedback form

This commit is contained in:
2026-04-28 13:43:22 +02:00
parent f30a963329
commit 5f96daf7f3
12 changed files with 708 additions and 8 deletions

View File

@@ -0,0 +1,128 @@
package market
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/email"
)
const (
FeedbackCategoryIncorrect = "incorrect_data"
FeedbackCategoryMissing = "missing_data"
FeedbackCategoryDuplicate = "duplicate"
FeedbackCategoryOther = "other"
FeedbackStatusNew = "new"
FeedbackStatusTriaged = "triaged"
FeedbackStatusResolved = "resolved"
FeedbackStatusSpam = "spam"
)
var validFeedbackCategories = map[string]bool{
FeedbackCategoryIncorrect: true,
FeedbackCategoryMissing: true,
FeedbackCategoryDuplicate: true,
FeedbackCategoryOther: true,
}
type Feedback struct {
ID uuid.UUID
MarketID *uuid.UUID
MarketSlug string
Category string
Email string
Message string
DuplicateURL string
Status string
RemoteIP string
CreatedAt time.Time
}
type SubmitFeedbackRequest struct {
Category string `json:"category" validate:"required,oneof=incorrect_data missing_data duplicate other"`
Email string `json:"email" validate:"required,email"`
Message string `json:"message" validate:"required,min=10,max=5000"`
DuplicateURL string `json:"duplicate_url" validate:"omitempty,url,max=500"`
TurnstileToken string `json:"turnstile_token" validate:"required"`
}
type SubmitFeedbackResponse struct {
Data SubmitFeedbackResponseData `json:"data"`
}
type SubmitFeedbackResponseData struct {
Message string `json:"message"`
}
var ErrFeedbackDuplicateURLRequired = errors.New("duplicate_url required for duplicate category")
func (s *Service) SubmitFeedback(ctx context.Context, slug string, req SubmitFeedbackRequest, remoteIP string) error {
if !validFeedbackCategories[req.Category] {
return fmt.Errorf("invalid category %q", req.Category)
}
if req.Category == FeedbackCategoryDuplicate && req.DuplicateURL == "" {
return ErrFeedbackDuplicateURLRequired
}
if err := s.turnstile.Verify(ctx, req.TurnstileToken, remoteIP); err != nil {
return fmt.Errorf("turnstile: %w", err)
}
m, err := s.repo.GetBySlug(ctx, slug)
if err != nil {
return fmt.Errorf("looking up market for feedback: %w", err)
}
fb := Feedback{
MarketID: &m.ID,
MarketSlug: slug,
Category: req.Category,
Email: req.Email,
Message: req.Message,
DuplicateURL: req.DuplicateURL,
Status: FeedbackStatusNew,
RemoteIP: remoteIP,
}
if _, err := s.repo.CreateFeedback(ctx, fb); err != nil {
return fmt.Errorf("creating feedback: %w", err)
}
if s.email != nil && s.adminEmail != "" {
go s.sendFeedbackNotification(m, slug, req)
}
return nil
}
func (s *Service) sendFeedbackNotification(m Market, slug string, req SubmitFeedbackRequest) {
subject := fmt.Sprintf("Markt-Feedback (%s): %s", req.Category, m.Name)
body := fmt.Sprintf(
"Neues Feedback zum Markt:\n\nName: %s\nSlug: %s\nKategorie: %s\nE-Mail: %s\n%sNachricht:\n%s\n\nMarkt im Admin-Panel: %s/admin/maerkte/%s/bearbeiten",
m.Name, slug, req.Category, req.Email,
duplicateURLLine(req),
req.Message,
s.frontendURL, m.ID,
)
if err := s.email.Send(context.Background(), email.Message{
To: s.adminEmail,
Subject: subject,
Body: body,
}); err != nil {
slog.Error("failed to send feedback notification", "error", err)
}
}
func duplicateURLLine(req SubmitFeedbackRequest) string {
if req.Category == FeedbackCategoryDuplicate && req.DuplicateURL != "" {
return fmt.Sprintf("Duplikat-URL: %s\n", req.DuplicateURL)
}
return ""
}

View File

@@ -0,0 +1,59 @@
package market
import (
"errors"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/turnstile"
"marktvogt.de/backend/internal/pkg/validate"
)
type FeedbackHandler struct {
service *Service
}
func NewFeedbackHandler(service *Service) *FeedbackHandler {
return &FeedbackHandler{service: service}
}
func (h *FeedbackHandler) Submit(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
apiErr := apierror.BadRequest("invalid_slug", "missing market slug")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req SubmitFeedbackRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
err := h.service.SubmitFeedback(c.Request.Context(), slug, req, c.ClientIP())
switch {
case err == nil:
c.JSON(http.StatusAccepted, SubmitFeedbackResponse{
Data: SubmitFeedbackResponseData{
Message: "Vielen Dank! Dein Feedback wurde uebermittelt.",
},
})
case errors.Is(err, ErrFeedbackDuplicateURLRequired):
apiErr := apierror.BadRequest("duplicate_url_required", "Bitte gib die URL des Duplikats an.")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, turnstile.ErrVerificationFailed):
apiErr := apierror.BadRequest("turnstile_failed", "spam protection verification failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrMarketNotFound):
apiErr := apierror.NotFound("market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
default:
slog.ErrorContext(c.Request.Context(), "submit feedback failed", "error", err, "slug", slug)
apiErr := apierror.Internal("failed to submit feedback")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
}
}

View File

@@ -0,0 +1,29 @@
package market
import (
"context"
"fmt"
)
func (r *pgRepository) CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error) {
const query = `
INSERT INTO market_feedback (
market_id, market_slug, category, email, message,
duplicate_url, status, remote_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at
`
if fb.Status == "" {
fb.Status = FeedbackStatusNew
}
if err := r.db.QueryRow(ctx, query,
fb.MarketID, fb.MarketSlug, fb.Category, fb.Email, fb.Message,
fb.DuplicateURL, fb.Status, fb.RemoteIP,
).Scan(&fb.ID, &fb.CreatedAt); err != nil {
return Feedback{}, fmt.Errorf("inserting feedback: %w", err)
}
return fb, nil
}

View File

@@ -0,0 +1,145 @@
package market
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
)
type stubTurnstile struct {
err error
}
func (s stubTurnstile) Verify(_ context.Context, _, _ string) error { return s.err }
// stubFeedbackRepo implements just enough of Repository for SubmitFeedback tests.
// Everything else panics, since SubmitFeedback doesn't call those paths.
type stubFeedbackRepo struct {
Repository
getBySlug func(ctx context.Context, slug string) (Market, error)
createFeedback func(ctx context.Context, fb Feedback) (Feedback, error)
createdCalls int
lastCreated Feedback
}
func (s *stubFeedbackRepo) GetBySlug(ctx context.Context, slug string) (Market, error) {
return s.getBySlug(ctx, slug)
}
func (s *stubFeedbackRepo) CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error) {
s.createdCalls++
s.lastCreated = fb
if s.createFeedback != nil {
return s.createFeedback(ctx, fb)
}
fb.ID = uuid.New()
return fb, nil
}
func newFeedbackService(t *testing.T, repo Repository, ts stubTurnstile) *Service {
t.Helper()
return &Service{
repo: repo,
turnstile: ts,
frontendURL: "https://example.test",
}
}
func TestSubmitFeedback_RejectsInvalidCategory(t *testing.T) {
svc := newFeedbackService(t, &stubFeedbackRepo{}, stubTurnstile{})
err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{
Category: "spam_us",
Email: "user@example.com",
Message: "valid message text",
TurnstileToken: "tok",
}, "1.2.3.4")
if err == nil {
t.Fatal("expected error for invalid category, got nil")
}
}
func TestSubmitFeedback_DuplicateRequiresURL(t *testing.T) {
svc := newFeedbackService(t, &stubFeedbackRepo{}, stubTurnstile{})
err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{
Category: FeedbackCategoryDuplicate,
Email: "user@example.com",
Message: "valid message text",
TurnstileToken: "tok",
}, "1.2.3.4")
if !errors.Is(err, ErrFeedbackDuplicateURLRequired) {
t.Fatalf("expected ErrFeedbackDuplicateURLRequired, got %v", err)
}
}
func TestSubmitFeedback_TurnstileFailureSkipsRepo(t *testing.T) {
wantErr := errors.New("turnstile rejected")
repo := &stubFeedbackRepo{
getBySlug: func(_ context.Context, _ string) (Market, error) {
t.Fatal("repo should not be called when turnstile fails")
return Market{}, nil
},
}
svc := newFeedbackService(t, repo, stubTurnstile{err: wantErr})
err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{
Category: FeedbackCategoryOther,
Email: "user@example.com",
Message: "valid message text",
TurnstileToken: "tok",
}, "1.2.3.4")
if err == nil || !errors.Is(err, wantErr) {
t.Fatalf("expected wrapped turnstile error, got %v", err)
}
if repo.createdCalls != 0 {
t.Fatalf("expected 0 CreateFeedback calls, got %d", repo.createdCalls)
}
}
func TestSubmitFeedback_PersistsWithMarketID(t *testing.T) {
marketID := uuid.New()
repo := &stubFeedbackRepo{
getBySlug: func(_ context.Context, slug string) (Market, error) {
if slug != "ronneburg-2026" {
t.Fatalf("unexpected slug: %s", slug)
}
return Market{ID: marketID, Name: "Ronneburg 2026"}, nil
},
}
svc := newFeedbackService(t, repo, stubTurnstile{})
err := svc.SubmitFeedback(context.Background(), "ronneburg-2026", SubmitFeedbackRequest{
Category: FeedbackCategoryDuplicate,
Email: "user@example.com",
Message: "Same as the other Ronneburg market",
DuplicateURL: "https://marktvogt.de/markt/ronneburg-2026-old",
TurnstileToken: "tok",
}, "1.2.3.4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.createdCalls != 1 {
t.Fatalf("expected 1 CreateFeedback call, got %d", repo.createdCalls)
}
if repo.lastCreated.MarketID == nil || *repo.lastCreated.MarketID != marketID {
t.Fatalf("expected market_id %s, got %v", marketID, repo.lastCreated.MarketID)
}
if repo.lastCreated.MarketSlug != "ronneburg-2026" {
t.Fatalf("expected slug ronneburg-2026, got %q", repo.lastCreated.MarketSlug)
}
if repo.lastCreated.DuplicateURL != "https://marktvogt.de/markt/ronneburg-2026-old" {
t.Fatalf("expected duplicate URL preserved, got %q", repo.lastCreated.DuplicateURL)
}
if repo.lastCreated.Status != FeedbackStatusNew {
t.Fatalf("expected status %q, got %q", FeedbackStatusNew, repo.lastCreated.Status)
}
if repo.lastCreated.RemoteIP != "1.2.3.4" {
t.Fatalf("expected remote IP recorded, got %q", repo.lastCreated.RemoteIP)
}
}

View File

@@ -41,6 +41,9 @@ type Repository interface {
SlugExists(ctx context.Context, slug string) (bool, error)
FindSimilar(ctx context.Context, id uuid.UUID, name, city string, startDate, endDate time.Time) ([]DuplicateCandidate, error)
// Feedback
CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error)
// Merge audit log
InsertMergeLog(ctx context.Context, entry MergeLogEntry) error

View File

@@ -2,12 +2,13 @@ package market
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, geoH *GeocodeHandler, submitLimit, geocodeLimit gin.HandlerFunc) {
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, geoH *GeocodeHandler, fbH *FeedbackHandler, submitLimit, geocodeLimit, feedbackLimit gin.HandlerFunc) {
markets := rg.Group("/markets")
{
markets.GET("", h.Search)
markets.GET("/:slug", h.GetBySlug)
markets.POST("/submit", submitLimit, subH.Submit)
markets.POST("/:slug/feedback", feedbackLimit, fbH.Submit)
}
rg.POST("/geocode", geocodeLimit, geoH.Geocode)

View File

@@ -83,10 +83,12 @@ func (s *Server) registerRoutes() {
marketSvc := market.NewServiceFull(marketRepo, emailSender, tsVerifier, geocoder, s.cfg.Notification.AdminEmail, s.cfg.Notification.FrontendURL)
marketHandler := market.NewHandler(marketSvc)
submissionHandler := market.NewSubmissionHandler(marketSvc)
feedbackHandler := market.NewFeedbackHandler(marketSvc)
geocodeHandler := market.NewGeocodeHandler(geocoder)
submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP
geocodeLimit := middleware.RateLimit(10.0/60.0, 10) // 10 per minute per IP
market.RegisterRoutes(v1, marketHandler, submissionHandler, geocodeHandler, submitLimit, geocodeLimit)
submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP
geocodeLimit := middleware.RateLimit(10.0/60.0, 10) // 10 per minute per IP
feedbackLimit := middleware.RateLimit(5.0/3600.0, 5) // 5 per hour per IP
market.RegisterRoutes(v1, marketHandler, submissionHandler, geocodeHandler, feedbackHandler, submitLimit, geocodeLimit, feedbackLimit)
// AI settings store + usage repo — used by AI provider and settings handler
encKey, err := apicrypto.DeriveKey([]byte(s.cfg.JWT.Secret))

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS market_feedback;

View File

@@ -0,0 +1,15 @@
CREATE TABLE market_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
market_id UUID REFERENCES market_editions(id) ON DELETE SET NULL,
market_slug TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('incorrect_data', 'missing_data', 'duplicate', 'other')),
email TEXT NOT NULL,
message TEXT NOT NULL,
duplicate_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'triaged', 'resolved', 'spam')),
remote_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX market_feedback_market_idx ON market_feedback (market_id, created_at DESC);
CREATE INDEX market_feedback_status_idx ON market_feedback (status, created_at DESC);

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
interface Props {
open: boolean;
marketName: string;
turnstileSiteKey: string;
form: {
error?: string;
category?: string;
email?: string;
message?: string;
duplicateUrl?: string;
feedback?: { success: boolean };
} | null;
onClose: () => void;
}
let { open, marketName, turnstileSiteKey, form, onClose }: Props = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let loading = $state(false);
let category = $state(form?.category ?? 'incorrect_data');
$effect(() => {
if (!dialogEl) return;
if (open && !dialogEl.open) {
dialogEl.showModal();
} else if (!open && dialogEl.open) {
dialogEl.close();
}
});
$effect(() => {
if (form?.feedback?.success && dialogEl?.open) {
// Keep open briefly to show the success state, then close.
const t = setTimeout(() => onClose(), 2500);
return () => clearTimeout(t);
}
});
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialogEl) onClose();
}
</script>
<svelte:head>
{#if turnstileSiteKey}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{/if}
</svelte:head>
<dialog
bind:this={dialogEl}
onclose={onClose}
onclick={handleBackdropClick}
class="bg-vellum w-full max-w-lg rounded-lg p-0 backdrop:bg-stone-900/50 dark:bg-stone-800"
>
<div class="border-b border-stone-200 px-6 py-4 dark:border-stone-700">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
Feedback zu „{marketName}"
</h2>
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
Hilf uns, die Daten korrekt und vollstaendig zu halten.
</p>
</div>
{#if form?.feedback?.success}
<div class="px-6 py-6">
<Alert variant="success">Danke! Dein Feedback wurde uebermittelt und wird geprueft.</Alert>
<div class="mt-4 flex justify-end">
<Button variant="secondary" onclick={onClose}>Schliessen</Button>
</div>
</div>
{:else}
<form
method="POST"
action="?/feedback"
class="px-6 py-5"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
{#if form?.error}
<div class="mb-4">
<Alert variant="error">{form.error}</Alert>
</div>
{/if}
<div class="space-y-4">
<div class="space-y-1">
<label
for="fb_category"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Kategorie *
</label>
<select
id="fb_category"
name="category"
bind:value={category}
required
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm shadow-sm focus:ring-2 focus:outline-none dark:border-stone-600 dark:bg-stone-800"
>
<option value="incorrect_data">Falsche Angaben</option>
<option value="missing_data">Fehlende Angaben</option>
<option value="duplicate">Duplikat eines anderen Marktes</option>
<option value="other">Sonstiges</option>
</select>
</div>
{#if category === 'duplicate'}
<div class="space-y-1">
<label
for="fb_duplicate_url"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
URL des Duplikats *
</label>
<input
id="fb_duplicate_url"
name="duplicate_url"
type="url"
required
placeholder="https://marktvogt.de/markt/..."
value={form?.duplicateUrl ?? ''}
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm shadow-sm focus:ring-2 focus:outline-none dark:border-stone-600 dark:bg-stone-800"
/>
</div>
{/if}
<div class="space-y-1">
<label
for="fb_email"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Deine E-Mail *
</label>
<input
id="fb_email"
name="email"
type="email"
required
value={form?.email ?? ''}
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm shadow-sm focus:ring-2 focus:outline-none dark:border-stone-600 dark:bg-stone-800"
/>
</div>
<div class="space-y-1">
<label
for="fb_message"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Nachricht *
</label>
<textarea
id="fb_message"
name="message"
required
minlength="10"
maxlength="5000"
rows="5"
placeholder="Beschreibe, was nicht stimmt oder fehlt."
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm shadow-sm focus:ring-2 focus:outline-none dark:border-stone-600 dark:bg-stone-800"
>{form?.message ?? ''}</textarea
>
</div>
<div
class="rounded-md bg-stone-100 p-3 text-xs leading-relaxed text-stone-600 dark:bg-stone-700/50 dark:text-stone-300"
>
<p class="font-medium text-stone-700 dark:text-stone-200">Hinweis zum Datenschutz</p>
<p class="mt-1">
Mit dem Absenden uebermittelst du: deine E-Mail-Adresse, Kategorie, Nachricht
{#if category === 'duplicate'}und die angegebene Duplikat-URL{/if}, sowie ID und Slug
dieses Marktes. Die E-Mail wird nur fuer Rueckfragen verwendet. Details siehe
<a href="/datenschutz" class="underline hover:no-underline">Datenschutzerklaerung</a>.
</p>
</div>
{#if turnstileSiteKey}
<div class="cf-turnstile" data-sitekey={turnstileSiteKey} data-theme="auto"></div>
{/if}
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" onclick={onClose} disabled={loading}>
Abbrechen
</Button>
<Button type="submit" variant="primary" {loading}>Senden</Button>
</div>
</form>
{/if}
</dialog>
<style>
dialog::backdrop {
background: rgba(28, 25, 23, 0.5);
}
</style>

View File

@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types.js';
import type { Actions, PageServerLoad } from './$types.js';
import { apiFetch, ApiClientError } from '$lib/api/client.js';
import type { MarketDetail, EditionBrief } from '$lib/api/types.js';
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { error, fail } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, url, fetch }) => {
const year = url.searchParams.get('year') ?? '';
@@ -11,7 +12,8 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
const res = await apiFetch<MarketDetail>(`/markets/${params.slug}${query}`, { fetch });
return {
market: res.data,
editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? []
editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? [],
turnstileSiteKey: env.PUBLIC_TURNSTILE_SITE_KEY ?? ''
};
} catch (e) {
if (e instanceof ApiClientError && e.status === 404) {
@@ -20,3 +22,53 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
error(500, { message: 'Fehler beim Laden des Marktes.' });
}
};
const FEEDBACK_CATEGORIES = ['incorrect_data', 'missing_data', 'duplicate', 'other'] as const;
export const actions: Actions = {
feedback: async ({ params, request, fetch }) => {
const form = await request.formData();
const category = (form.get('category')?.toString() ?? '').trim();
const email = (form.get('email')?.toString() ?? '').trim();
const message = (form.get('message')?.toString() ?? '').trim();
const duplicateUrl = (form.get('duplicate_url')?.toString() ?? '').trim();
const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? '';
const formState = { category, email, message, duplicateUrl };
if (!FEEDBACK_CATEGORIES.includes(category as (typeof FEEDBACK_CATEGORIES)[number])) {
return fail(400, { error: 'Bitte waehle eine Kategorie.', ...formState });
}
if (!email || !message) {
return fail(400, { error: 'Bitte fuelle alle Pflichtfelder aus.', ...formState });
}
if (message.length < 10) {
return fail(400, { error: 'Die Nachricht ist zu kurz (min. 10 Zeichen).', ...formState });
}
if (category === 'duplicate' && !duplicateUrl) {
return fail(400, { error: 'Bitte gib die URL des Duplikats an.', ...formState });
}
if (!turnstileToken) {
return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState });
}
try {
await apiFetch(`/markets/${params.slug}/feedback`, {
method: 'POST',
body: JSON.stringify({
category,
email,
message,
duplicate_url: duplicateUrl,
turnstile_token: turnstileToken
}),
fetch
});
return { feedback: { success: true } };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler.';
return fail(500, { error: msg, ...formState });
}
}
};

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import MarketMap from '$lib/components/market/MarketMap.svelte';
import MarketFeedbackDialog from '$lib/components/market/MarketFeedbackDialog.svelte';
import type {
MarketDetail,
OpeningHoursEntry,
@@ -8,9 +9,17 @@
} from '$lib/api/types.js';
import { stateToSlug, toSlug } from '$lib/utils/slug.js';
let { data } = $props();
let { data, form } = $props();
const market: MarketDetail = $derived(data.market);
const editions: EditionBrief[] = $derived(data.editions);
const isAdmin = $derived(data.user?.role === 'admin');
let feedbackOpen = $state(false);
$effect(() => {
if (form?.error || form?.feedback?.success) {
feedbackOpen = true;
}
});
const currentYear = $derived(new Date(market.start_date).getFullYear());
const hasMultipleEditions = $derived(editions.length > 1);
const stateSlug = $derived(stateToSlug(market.state));
@@ -483,4 +492,54 @@
class="h-[300px]"
/>
</div>
<div
class="mt-8 flex flex-wrap items-center justify-end gap-3 border-t border-stone-200 pt-6 dark:border-stone-700"
>
<button
type="button"
onclick={() => (feedbackOpen = true)}
class="inline-flex items-center gap-1.5 text-sm font-medium text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
Falsche oder fehlende Angaben melden
</button>
{#if isAdmin}
<a
href="/admin/maerkte/{market.id}/bearbeiten"
class="inline-flex items-center gap-1.5 rounded-md border border-amber-400/60 bg-amber-50 px-2.5 py-1 text-sm font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-200 dark:hover:bg-amber-900/50"
title="Nur Admins sehen diesen Link"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
/>
</svg>
Bearbeiten
</a>
{/if}
</div>
</div>
<MarketFeedbackDialog
open={feedbackOpen}
marketName={market.name}
turnstileSiteKey={data.turnstileSiteKey}
{form}
onClose={() => (feedbackOpen = false)}
/>