feat(market): admin edit link and public feedback form
This commit is contained in:
128
backend/internal/domain/market/feedback.go
Normal file
128
backend/internal/domain/market/feedback.go
Normal 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 ""
|
||||
}
|
||||
59
backend/internal/domain/market/feedback_handler.go
Normal file
59
backend/internal/domain/market/feedback_handler.go
Normal 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))
|
||||
}
|
||||
}
|
||||
29
backend/internal/domain/market/feedback_repo.go
Normal file
29
backend/internal/domain/market/feedback_repo.go
Normal 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
|
||||
}
|
||||
145
backend/internal/domain/market/feedback_test.go
Normal file
145
backend/internal/domain/market/feedback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
1
backend/migrations/000032_market_feedback.down.sql
Normal file
1
backend/migrations/000032_market_feedback.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS market_feedback;
|
||||
15
backend/migrations/000032_market_feedback.up.sql
Normal file
15
backend/migrations/000032_market_feedback.up.sql
Normal 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);
|
||||
206
web/src/lib/components/market/MarketFeedbackDialog.svelte
Normal file
206
web/src/lib/components/market/MarketFeedbackDialog.svelte
Normal 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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user