mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 12:19:40 +02:00
feat(citycorners): add review and rating system
- New reviews table (userId, locationId, rating 1-5, comment, unique per user) - ReviewService with CRUD, stats aggregation, batch stats for lists - ReviewController with GET/POST/DELETE endpoints at /reviews/:locationId - Location list and detail endpoints now include reviewStats (avg + count) - Star rating display on location cards (home page) - Full review section on detail page: star picker, comment, submit, delete - i18n: 13 new review-related translations (DE/EN) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1edbc190a6
commit
7303709710
12 changed files with 486 additions and 3 deletions
|
|
@ -4,6 +4,7 @@ import { DatabaseModule } from './db/database.module';
|
|||
import { LocationModule } from './location/location.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { CollectionModule } from './collection/collection.module';
|
||||
import { ReviewModule } from './review/review.module';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
|||
LocationModule,
|
||||
FavoriteModule,
|
||||
CollectionModule,
|
||||
ReviewModule,
|
||||
HealthModule.forRoot({ serviceName: 'citycorners-backend' }),
|
||||
MetricsModule.register({
|
||||
prefix: 'citycorners_',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './locations.schema';
|
||||
export * from './favorites.schema';
|
||||
export * from './collections.schema';
|
||||
export * from './reviews.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { pgTable, uuid, text, integer, timestamp, unique } from 'drizzle-orm/pg-core';
|
||||
import { locations } from './locations.schema';
|
||||
|
||||
export const reviews = pgTable(
|
||||
'reviews',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
locationId: uuid('location_id')
|
||||
.notNull()
|
||||
.references(() => locations.id, { onDelete: 'cascade' }),
|
||||
rating: integer('rating').notNull(),
|
||||
comment: text('comment'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
uniqueUserLocation: unique().on(table.userId, table.locationId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Review = typeof reviews.$inferSelect;
|
||||
export type NewReview = typeof reviews.$inferInsert;
|
||||
|
|
@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } fro
|
|||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { LocationService } from './location.service';
|
||||
import { LocationLookupService } from './location-lookup.service';
|
||||
import { ReviewService } from '../review/review.service';
|
||||
import { RateLimitGuard } from '../guards/rate-limit.guard';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsObject } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
|
@ -121,7 +122,8 @@ class UpdateLocationDto {
|
|||
export class LocationController {
|
||||
constructor(
|
||||
private readonly locationService: LocationService,
|
||||
private readonly lookupService: LocationLookupService
|
||||
private readonly lookupService: LocationLookupService,
|
||||
private readonly reviewService: ReviewService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
|
|
@ -134,8 +136,14 @@ export class LocationController {
|
|||
const limitNum = limit ? Math.min(100, Math.max(1, parseInt(limit, 10))) : 20;
|
||||
|
||||
const result = await this.locationService.findAll(category, pageNum, limitNum);
|
||||
const locationIds = result.items.map((l) => l.id);
|
||||
const reviewStats = await this.reviewService.getStatsForLocations(locationIds);
|
||||
|
||||
return {
|
||||
locations: result.items,
|
||||
locations: result.items.map((l) => ({
|
||||
...l,
|
||||
reviewStats: reviewStats[l.id] || { averageRating: 0, totalReviews: 0 },
|
||||
})),
|
||||
pagination: {
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
|
|
@ -175,7 +183,8 @@ export class LocationController {
|
|||
@Get(':id')
|
||||
async findById(@Param('id') id: string) {
|
||||
const location = await this.locationService.findByIdOrSlug(id);
|
||||
return { location };
|
||||
const reviewStats = await this.reviewService.getStats(location.id);
|
||||
return { location: { ...location, reviewStats } };
|
||||
}
|
||||
|
||||
@Get(':id/nearby')
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||
import { LocationController } from './location.controller';
|
||||
import { LocationService } from './location.service';
|
||||
import { LocationLookupService } from './location-lookup.service';
|
||||
import { ReviewModule } from '../review/review.module';
|
||||
|
||||
@Module({
|
||||
imports: [ReviewModule],
|
||||
controllers: [LocationController],
|
||||
providers: [LocationService, LocationLookupService],
|
||||
exports: [LocationService],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { Controller, Get, Post, Delete, Param, Body, UseGuards, Query } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ReviewService } from './review.service';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class CreateReviewDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
@Type(() => Number)
|
||||
rating!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
@Controller('reviews')
|
||||
export class ReviewController {
|
||||
constructor(private readonly reviewService: ReviewService) {}
|
||||
|
||||
@Get(':locationId')
|
||||
async findByLocation(@Param('locationId') locationId: string) {
|
||||
const [reviewsList, stats] = await Promise.all([
|
||||
this.reviewService.findByLocationId(locationId),
|
||||
this.reviewService.getStats(locationId),
|
||||
]);
|
||||
return { reviews: reviewsList, stats };
|
||||
}
|
||||
|
||||
@Get(':locationId/stats')
|
||||
async getStats(@Param('locationId') locationId: string) {
|
||||
const stats = await this.reviewService.getStats(locationId);
|
||||
return { stats };
|
||||
}
|
||||
|
||||
@Post(':locationId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('locationId') locationId: string,
|
||||
@Body() dto: CreateReviewDto
|
||||
) {
|
||||
const review = await this.reviewService.create(
|
||||
user.userId,
|
||||
locationId,
|
||||
dto.rating,
|
||||
dto.comment
|
||||
);
|
||||
return { review };
|
||||
}
|
||||
|
||||
@Delete(':locationId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async remove(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) {
|
||||
await this.reviewService.delete(user.userId, locationId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/citycorners/apps/backend/src/review/review.module.ts
Normal file
10
apps/citycorners/apps/backend/src/review/review.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReviewController } from './review.controller';
|
||||
import { ReviewService } from './review.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReviewController],
|
||||
providers: [ReviewService],
|
||||
exports: [ReviewService],
|
||||
})
|
||||
export class ReviewModule {}
|
||||
91
apps/citycorners/apps/backend/src/review/review.service.ts
Normal file
91
apps/citycorners/apps/backend/src/review/review.service.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Injectable, Inject, ConflictException } from '@nestjs/common';
|
||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { reviews } from '../db/schema';
|
||||
import type { Review } from '../db/schema';
|
||||
|
||||
export interface ReviewStats {
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReviewService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByLocationId(locationId: string): Promise<Review[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(reviews)
|
||||
.where(eq(reviews.locationId, locationId))
|
||||
.orderBy(desc(reviews.createdAt));
|
||||
}
|
||||
|
||||
async getStats(locationId: string): Promise<ReviewStats> {
|
||||
const [result] = await this.db
|
||||
.select({
|
||||
averageRating: sql<number>`coalesce(round(avg(${reviews.rating})::numeric, 1), 0)::float`,
|
||||
totalReviews: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(reviews)
|
||||
.where(eq(reviews.locationId, locationId));
|
||||
|
||||
return result || { averageRating: 0, totalReviews: 0 };
|
||||
}
|
||||
|
||||
async getStatsForLocations(locationIds: string[]): Promise<Record<string, ReviewStats>> {
|
||||
if (locationIds.length === 0) return {};
|
||||
|
||||
const results = await this.db
|
||||
.select({
|
||||
locationId: reviews.locationId,
|
||||
averageRating: sql<number>`round(avg(${reviews.rating})::numeric, 1)::float`,
|
||||
totalReviews: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(reviews)
|
||||
.where(
|
||||
sql`${reviews.locationId} = ANY(${sql.raw(`ARRAY[${locationIds.map((id) => `'${id}'::uuid`).join(',')}]`)})`
|
||||
)
|
||||
.groupBy(reviews.locationId);
|
||||
|
||||
const map: Record<string, ReviewStats> = {};
|
||||
for (const r of results) {
|
||||
map[r.locationId] = { averageRating: r.averageRating, totalReviews: r.totalReviews };
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
locationId: string,
|
||||
rating: number,
|
||||
comment?: string
|
||||
): Promise<Review> {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(reviews)
|
||||
.where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId)));
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException('You have already reviewed this location');
|
||||
}
|
||||
|
||||
const [review] = await this.db
|
||||
.insert(reviews)
|
||||
.values({
|
||||
userId,
|
||||
locationId,
|
||||
rating: Math.min(5, Math.max(1, rating)),
|
||||
comment: comment || null,
|
||||
})
|
||||
.returning();
|
||||
return review;
|
||||
}
|
||||
|
||||
async delete(userId: string, locationId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(reviews)
|
||||
.where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +191,22 @@
|
|||
"loadError": "Ort konnte nicht geladen werden.",
|
||||
"forbidden": "Du kannst nur deine eigenen Orte bearbeiten."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Bewertungen",
|
||||
"write": "Bewertung schreiben",
|
||||
"yourRating": "Deine Bewertung",
|
||||
"commentPlaceholder": "Was hat dir gefallen? (optional)",
|
||||
"submit": "Absenden",
|
||||
"submitting": "Wird gesendet...",
|
||||
"loginRequired": "Melde dich an, um eine Bewertung zu schreiben.",
|
||||
"alreadyReviewed": "Du hast diesen Ort bereits bewertet.",
|
||||
"deleteConfirm": "Bewertung löschen?",
|
||||
"delete": "Löschen",
|
||||
"noReviews": "Noch keine Bewertungen. Sei der Erste!",
|
||||
"error": "Bewertung konnte nicht gespeichert werden.",
|
||||
"count": "{count} Bewertungen",
|
||||
"countOne": "1 Bewertung"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Keine Verbindung",
|
||||
"message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.",
|
||||
|
|
|
|||
|
|
@ -191,6 +191,22 @@
|
|||
"loadError": "Could not load place.",
|
||||
"forbidden": "You can only edit your own places."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reviews",
|
||||
"write": "Write a review",
|
||||
"yourRating": "Your rating",
|
||||
"commentPlaceholder": "What did you like? (optional)",
|
||||
"submit": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"loginRequired": "Sign in to write a review.",
|
||||
"alreadyReviewed": "You have already reviewed this place.",
|
||||
"deleteConfirm": "Delete review?",
|
||||
"delete": "Delete",
|
||||
"noReviews": "No reviews yet. Be the first!",
|
||||
"error": "Could not save review.",
|
||||
"count": "{count} reviews",
|
||||
"countOne": "1 review"
|
||||
},
|
||||
"offline": {
|
||||
"title": "No connection",
|
||||
"message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
longitude?: number;
|
||||
imageUrl?: string;
|
||||
openingHours?: Record<string, string>;
|
||||
reviewStats?: { averageRating: number; totalReviews: number };
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
|
|
@ -282,6 +283,28 @@
|
|||
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
|
||||
{location.description}
|
||||
</p>
|
||||
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-foreground-secondary">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 {i < Math.round(location.reviewStats.averageRating)
|
||||
? 'text-amber-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
<span>{location.reviewStats.averageRating.toFixed(1)}</span>
|
||||
<span class="text-foreground-secondary/60">({location.reviewStats.totalReviews})</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,19 @@
|
|||
distance: number;
|
||||
}
|
||||
|
||||
interface ReviewStats {
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
comment?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
slug?: string;
|
||||
|
|
@ -44,6 +57,7 @@
|
|||
website?: string;
|
||||
phone?: string;
|
||||
openingHours?: Record<string, string>;
|
||||
reviewStats?: ReviewStats;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +69,18 @@
|
|||
let showDeleteConfirm = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
// Review state
|
||||
let reviews = $state<Review[]>([]);
|
||||
let reviewRating = $state(0);
|
||||
let reviewComment = $state('');
|
||||
let submittingReview = $state(false);
|
||||
let reviewError = $state('');
|
||||
let showReviewForm = $state(false);
|
||||
|
||||
let userHasReviewed = $derived(
|
||||
authStore.isAuthenticated && reviews.some((r) => r.userId === authStore.user?.id)
|
||||
);
|
||||
|
||||
// Gallery state
|
||||
let selectedImageIndex = $state(0);
|
||||
let showAddPhoto = $state(false);
|
||||
|
|
@ -117,6 +143,9 @@
|
|||
if (authStore.isAuthenticated) {
|
||||
favoritesStore.load();
|
||||
}
|
||||
|
||||
// Load reviews
|
||||
loadReviews();
|
||||
});
|
||||
|
||||
// Initialize mini map after location loads
|
||||
|
|
@ -213,6 +242,76 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadReviews() {
|
||||
if (!location) return;
|
||||
try {
|
||||
const res = await fetch(api(`/reviews/${location.id}`));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
reviews = data.reviews || [];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReview() {
|
||||
if (!location || submittingReview || reviewRating === 0) return;
|
||||
submittingReview = true;
|
||||
reviewError = '';
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(api(`/reviews/${location.id}`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
rating: reviewRating,
|
||||
comment: reviewComment.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
reviewRating = 0;
|
||||
reviewComment = '';
|
||||
showReviewForm = false;
|
||||
await loadReviews();
|
||||
// Update review stats on the location
|
||||
const statsRes = await fetch(api(`/reviews/${location.id}/stats`));
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
location = { ...location, reviewStats: statsData.stats };
|
||||
}
|
||||
} else {
|
||||
reviewError = $_('reviews.error');
|
||||
}
|
||||
} catch {
|
||||
reviewError = $_('reviews.error');
|
||||
} finally {
|
||||
submittingReview = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteReview() {
|
||||
if (!location) return;
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
await fetch(api(`/reviews/${location.id}`), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await loadReviews();
|
||||
const statsRes = await fetch(api(`/reviews/${location.id}/stats`));
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
location = { ...location, reviewStats: statsData.stats };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${meters} m`;
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
|
|
@ -521,6 +620,138 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reviews -->
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-semibold text-foreground">{$_('reviews.title')}</h2>
|
||||
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
<svg
|
||||
class="h-4 w-4 {i < Math.round(location.reviewStats.averageRating)
|
||||
? 'text-amber-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{location.reviewStats.averageRating.toFixed(1)}
|
||||
</span>
|
||||
<span class="text-sm text-foreground-secondary">
|
||||
({location.reviewStats.totalReviews})
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if authStore.isAuthenticated && !userHasReviewed}
|
||||
<button
|
||||
onclick={() => (showReviewForm = !showReviewForm)}
|
||||
class="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{$_('reviews.write')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Review form -->
|
||||
{#if showReviewForm}
|
||||
<div class="mb-4 rounded-xl border border-border bg-background-card p-4">
|
||||
<p class="mb-3 text-sm font-medium text-foreground">{$_('reviews.yourRating')}</p>
|
||||
{#if reviewError}
|
||||
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">
|
||||
{reviewError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-3 flex gap-1">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
onclick={() => (reviewRating = star)}
|
||||
class="transition-transform hover:scale-110"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 {star <= reviewRating
|
||||
? 'text-amber-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={reviewComment}
|
||||
placeholder={$_('reviews.commentPlaceholder')}
|
||||
rows="2"
|
||||
class="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
onclick={handleSubmitReview}
|
||||
disabled={reviewRating === 0 || submittingReview}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submittingReview ? $_('reviews.submitting') : $_('reviews.submit')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Review list -->
|
||||
{#if reviews.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each reviews as review}
|
||||
<div class="rounded-xl border border-border bg-background-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Array(5) as _, i}
|
||||
<svg
|
||||
class="h-4 w-4 {i < review.rating
|
||||
? 'text-amber-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-foreground-secondary">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
{#if authStore.isAuthenticated && review.userId === authStore.user?.id}
|
||||
<button
|
||||
onclick={handleDeleteReview}
|
||||
class="text-xs text-red-500 hover:underline"
|
||||
>
|
||||
{$_('reviews.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if review.comment}
|
||||
<p class="mt-2 text-sm text-foreground-secondary">{review.comment}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !showReviewForm}
|
||||
<p class="text-sm text-foreground-secondary">{$_('reviews.noReviews')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Owner actions -->
|
||||
{#if isOwner}
|
||||
<div class="flex gap-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue