diff --git a/frontend/app/src/app/modules/data/data.module.ts b/frontend/app/src/app/modules/data/data.module.ts
index 9949a445..2fa72694 100644
--- a/frontend/app/src/app/modules/data/data.module.ts
+++ b/frontend/app/src/app/modules/data/data.module.ts
@@ -98,6 +98,7 @@ import {PeriodicalDetailContentComponent} from './types/periodical/periodical-de
import {DataListItemHostDirective} from './list/data-list-item-host.directive';
import {DataListItemHostDefaultComponent} from './list/data-list-item-host-default.component';
import {browserFactory, SimpleBrowser} from '../../util/browser.factory';
+import {StappsRatingComponent} from './elements/rating.component';
import {DishCharacteristicsComponent} from './types/dish/dish-characteristics.component';
import {SkeletonListComponent} from './list/skeleton-list.component';
@@ -155,6 +156,7 @@ import {SkeletonListComponent} from './list/skeleton-list.component';
SimpleCardComponent,
SkeletonListItemComponent,
SkeletonSegmentComponent,
+ StappsRatingComponent,
SkeletonSimpleCardComponent,
TreeListComponent,
TreeListFragmentComponent,
@@ -170,7 +172,6 @@ import {SkeletonListComponent} from './list/skeleton-list.component';
PeriodicalListItemComponent,
PeriodicalDetailContentComponent,
],
- entryComponents: [DataListComponent, SimpleDataListComponent],
imports: [
CommonModule,
DataRoutingModule,
diff --git a/frontend/app/src/app/modules/data/elements/rating.animation.ts b/frontend/app/src/app/modules/data/elements/rating.animation.ts
new file mode 100644
index 00000000..851afa20
--- /dev/null
+++ b/frontend/app/src/app/modules/data/elements/rating.animation.ts
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {animate, group, keyframes, query, stagger, style, transition, trigger} from '@angular/animations';
+
+const duration = '800ms';
+const timingFunction = 'cubic-bezier(0.16, 1, 0.3, 1)';
+const timing = `${duration} ${timingFunction}`;
+const staggerTiming = '75ms';
+
+export const ratingAnimation = trigger('rating', [
+ transition('rated => void', [
+ style({opacity: 1, translate: '0 0'}),
+ group([
+ query(
+ 'ion-icon:not(.rated-value)',
+ [
+ style({translate: '0 0', opacity: 1}),
+ stagger(staggerTiming, animate(timing, style({translate: '0 -16px', opacity: 0}))),
+ ],
+ {optional: true},
+ ),
+ query(
+ '.rated-value',
+ [
+ style({translate: '0 0', opacity: 1}),
+ animate(`${duration} 200ms ${timingFunction}`, style({translate: '0 -16px', opacity: 0})),
+ ],
+ {optional: true},
+ ),
+ query(
+ '.rated-value ~ .thank-you',
+ [
+ style({translate: '0 16px', opacity: 0}),
+ animate(`${duration} 500ms ${timingFunction}`, style({translate: '0 0', opacity: 1})),
+ animate('1000ms', style({opacity: 1})),
+ ],
+ {optional: true},
+ ),
+ ]),
+ animate(timing, style({opacity: 0, translate: '8px 0'})),
+ ]),
+ transition('abandoned => void', [
+ style({opacity: 1, translate: '0 0'}),
+ animate(timing, style({opacity: 0, translate: '8px 0'})),
+ ]),
+ transition(':enter', [
+ style({
+ translate: '40% 0',
+ background: 'rgba(var(--ion-color-light), 0)',
+ }),
+ group([
+ animate(
+ timing,
+ style({
+ translate: '0 0',
+ background: 'rgba(var(--ion-color-light-rgb), 1)',
+ }),
+ ),
+ query('ion-icon', [
+ style({
+ translate: '16px 0',
+ scale: '0.4 0.2',
+ opacity: 0,
+ offset: 0,
+ willChange: 'scale, translate',
+ }),
+ stagger(staggerTiming, [
+ animate(
+ timing,
+ keyframes([
+ style({
+ scale: 1.3,
+ offset: 0.2,
+ }),
+ style({
+ translate: '0 0',
+ scale: '1 1',
+ opacity: 1,
+ offset: 1,
+ }),
+ ]),
+ ),
+ ]),
+ ]),
+ ]),
+ ]),
+]);
diff --git a/frontend/app/src/app/modules/data/elements/rating.component.ts b/frontend/app/src/app/modules/data/elements/rating.component.ts
new file mode 100644
index 00000000..03f423cb
--- /dev/null
+++ b/frontend/app/src/app/modules/data/elements/rating.component.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {Component, ElementRef, HostListener, Input} from '@angular/core';
+import {SCDish, SCRatingRequest, SCUuid} from '@openstapps/core';
+import {RatingProvider} from '../rating.provider';
+import {ratingAnimation} from './rating.animation';
+
+@Component({
+ selector: 'stapps-rating',
+ templateUrl: 'rating.html',
+ styleUrls: ['rating.scss'],
+ animations: [ratingAnimation],
+})
+export class StappsRatingComponent {
+ rate = false;
+
+ rated = false;
+
+ canBeRated = false;
+
+ uid: SCUuid;
+
+ rating?: number;
+
+ @Input() set item(value: SCDish) {
+ this.uid = value.uid;
+
+ Promise.all([this.ratingProvider.canRate(value), this.ratingProvider.hasRated(this.uid)] as const).then(
+ ([canRate, hasRated]) => {
+ this.canBeRated = canRate;
+ this.rated = hasRated;
+ },
+ );
+ }
+
+ constructor(readonly elementRef: ElementRef, readonly ratingProvider: RatingProvider) {}
+
+ async submitRating(rating: number) {
+ this.rating = rating;
+ try {
+ await this.ratingProvider.rate(this.uid, rating as SCRatingRequest['rating']);
+ this.rated = true;
+ } catch {
+ this.rating = undefined;
+ // allow change detection to catch up first
+ setTimeout(() => {
+ this.rate = false;
+ });
+ }
+ }
+
+ @HostListener('document:mousedown', ['$event'])
+ clickOutside(event: MouseEvent) {
+ if (this.rating) return;
+ if (!this.elementRef.nativeElement.contains(event.target)) {
+ this.rate = false;
+ }
+ }
+}
diff --git a/frontend/app/src/app/modules/data/elements/rating.html b/frontend/app/src/app/modules/data/elements/rating.html
new file mode 100644
index 00000000..2248eb4b
--- /dev/null
+++ b/frontend/app/src/app/modules/data/elements/rating.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/src/app/modules/data/elements/rating.scss b/frontend/app/src/app/modules/data/elements/rating.scss
new file mode 100644
index 00000000..3671225f
--- /dev/null
+++ b/frontend/app/src/app/modules/data/elements/rating.scss
@@ -0,0 +1,66 @@
+/*!
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+.rating-stars {
+ display: flex;
+ contain: content;
+ flex-direction: row-reverse;
+ justify-content: start;
+ align-items: center;
+ position: absolute;
+ background: var(--ion-color-light);
+ bottom: 0;
+ right: 0;
+ top: 0;
+ gap: var(--spacing-md);
+ padding-inline: var(--spacing-xl);
+ border-radius: var(--border-radius-default);
+
+ > ion-icon {
+ @media (hover: hover) {
+ &:hover ~ *::ng-deep stapps-icon,
+ &:hover::ng-deep stapps-icon {
+ --fill: 1;
+ }
+ }
+ &:has(:checked)::ng-deep ~ *::ng-deep stapps-icon,
+ &:has(:checked)::ng-deep stapps-icon,
+ &:active::ng-deep ~ *::ng-deep stapps-icon,
+ &:active::ng-deep stapps-icon {
+ --fill: 1;
+ color: var(--ion-color-dark);
+ }
+ }
+
+ > .thank-you {
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ padding: var(--spacing-md);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+}
+
+ion-button {
+ margin: 0;
+
+ &.button-disabled::ng-deep stapps-icon {
+ --fill: 1;
+ }
+}
diff --git a/frontend/app/src/app/modules/data/list/data-list-item.component.ts b/frontend/app/src/app/modules/data/list/data-list-item.component.ts
index 27bbf8d1..36d41818 100644
--- a/frontend/app/src/app/modules/data/list/data-list-item.component.ts
+++ b/frontend/app/src/app/modules/data/list/data-list-item.component.ts
@@ -36,7 +36,7 @@ export class DataListItemComponent {
*/
@Input() item: SCThings;
- @Input() favoriteButton = true;
+ @Input() listItemEndInteraction = true;
@Input() lines = 'inset';
diff --git a/frontend/app/src/app/modules/data/list/data-list-item.html b/frontend/app/src/app/modules/data/list/data-list-item.html
index 3b785215..d8f1f562 100644
--- a/frontend/app/src/app/modules/data/list/data-list-item.html
+++ b/frontend/app/src/app/modules/data/list/data-list-item.html
@@ -32,7 +32,10 @@
-
+
+
+
+
diff --git a/frontend/app/src/app/modules/data/list/data-list-item.scss b/frontend/app/src/app/modules/data/list/data-list-item.scss
index aa4883a7..7649eea0 100644
--- a/frontend/app/src/app/modules/data/list/data-list-item.scss
+++ b/frontend/app/src/app/modules/data/list/data-list-item.scss
@@ -34,7 +34,7 @@ ion-item {
@include border-radius-in-parallax(var(--border-radius-default));
overflow: hidden;
--inner-padding-end: 0;
- --padding-start: var(--spacing-sm);
+ --padding-start: 0;
margin: var(--spacing-sm);
ion-thumbnail {
@@ -44,6 +44,7 @@ ion-item {
ion-label {
width: 100%;
margin-right: 0;
+ padding-left: var(--spacing-sm);
div {
display: flex;
@@ -68,11 +69,12 @@ ion-item {
flex-basis: min-content;
}
+ stapps-long-inline-text,
.title {
display: -webkit-box;
white-space: break-spaces;
-webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
+ -webkit-line-clamp: 2;
overflow: hidden;
}
@@ -80,11 +82,33 @@ ion-item {
display: none;
}
+ stapps-rating,
+ stapps-favorite-button {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+
+ stapps-rating {
+ width: 100%;
+
+ ion-button {
+ float: right;
+ }
+
+ .rating-stars {
+ width: 100%;
+ justify-content: center;
+ gap: var(--spacing-xs);
+ padding-inline: 0;
+ }
+ }
+
// fix for Safari
stapps-offers-in-list {
position: absolute;
bottom: 0;
- right: 0;
+ left: 0;
}
stapps-offers-in-list .place {
diff --git a/frontend/app/src/app/modules/data/rating.provider.ts b/frontend/app/src/app/modules/data/rating.provider.ts
new file mode 100644
index 00000000..3841acc6
--- /dev/null
+++ b/frontend/app/src/app/modules/data/rating.provider.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {Injectable} from '@angular/core';
+import {
+ SCAcademicPriceGroup,
+ SCDish,
+ SCISO8601Date,
+ SCRatingRequest,
+ SCRatingResponse,
+ SCRatingRoute,
+ SCUserGroup,
+ SCUserGroupSetting,
+ SCUuid,
+} from '@openstapps/core';
+import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
+import {StorageProvider} from '../storage/storage.provider';
+import {Client} from '@openstapps/api';
+import {environment} from '../../../environments/environment';
+import {SettingsProvider} from '../settings/settings.provider';
+
+interface RatingStorage {
+ date: SCISO8601Date;
+ uuids: Record;
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class RatingProvider {
+ private readonly client: Client;
+
+ private readonly backendUrl = environment.backend_url;
+
+ private readonly appVersion = environment.backend_version;
+
+ private readonly ratingRoute = new SCRatingRoute();
+
+ private readonly storageKey = 'rating.rated_uuids';
+
+ constructor(
+ stAppsWebHttpClient: StAppsWebHttpClient,
+ private readonly storageProvider: StorageProvider,
+ private readonly settingsProvider: SettingsProvider,
+ ) {
+ this.client = new Client(stAppsWebHttpClient, this.backendUrl, this.appVersion);
+ }
+
+ private get today() {
+ const today = new Date();
+ return new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
+ }
+
+ private get userGroup(): Promise {
+ return this.settingsProvider
+ .getSetting('profile', 'group')
+ .then(it => (it as SCUserGroupSetting).value as SCUserGroup);
+ }
+
+ private async getStoredRatings(): Promise {
+ const has = await this.storageProvider.has(this.storageKey);
+ const current = has
+ ? await this.storageProvider.get(this.storageKey)
+ : undefined;
+ const expired = current?.date !== this.today;
+ return expired
+ ? await this.storageProvider.put(this.storageKey, {
+ date: this.today,
+ uuids: {},
+ })
+ : current!;
+ }
+
+ private async setStoredRatings(value: RatingStorage | undefined) {
+ await (value ? this.storageProvider.put(this.storageKey, value) : this.storageProvider.delete());
+ }
+
+ private async markAsRated(uid: SCUuid) {
+ const rating = await this.getStoredRatings();
+ await this.setStoredRatings({
+ ...rating,
+ uuids: {...rating.uuids, [uid]: true},
+ });
+ }
+
+ async rate(uid: SCUuid, rating: SCRatingRequest['rating']) {
+ const request: SCRatingRequest = {
+ rating: rating,
+ userGroup: await this.userGroup,
+ uid,
+ };
+ const response = await this.client.invokeRoute(this.ratingRoute, undefined, request);
+
+ await this.markAsRated(uid);
+
+ return response;
+ }
+
+ async canRate(dish: SCDish): Promise {
+ const userGroup = (
+ {
+ students: 'student',
+ employees: 'employee',
+ guests: 'guest',
+ } as Record
+ )[await this.userGroup];
+ return dish.offers?.find(it => it.prices?.[userGroup]) !== undefined;
+ }
+
+ async hasRated(uid: SCUuid): Promise {
+ const ratings = await this.getStoredRatings();
+ return ratings.uuids[uid] ?? false;
+ }
+}
diff --git a/frontend/app/src/app/modules/data/types/dish/dish-detail-content.html b/frontend/app/src/app/modules/data/types/dish/dish-detail-content.html
index ec1305bc..438b8f0c 100644
--- a/frontend/app/src/app/modules/data/types/dish/dish-detail-content.html
+++ b/frontend/app/src/app/modules/data/types/dish/dish-detail-content.html
@@ -72,3 +72,5 @@
[content]="'additives' | thingTranslate: item | join: ', '"
>
+
+
diff --git a/frontend/app/src/app/modules/data/types/dish/dish-detail-content.scss b/frontend/app/src/app/modules/data/types/dish/dish-detail-content.scss
index 7c94e516..f55ec0a1 100644
--- a/frontend/app/src/app/modules/data/types/dish/dish-detail-content.scss
+++ b/frontend/app/src/app/modules/data/types/dish/dish-detail-content.scss
@@ -16,3 +16,8 @@ stapps-dish-characteristics {
margin: var(--spacing-lg);
margin-block-end: var(--spacing-sm);
}
+
+stapps-rating {
+ position: absolute;
+ inset: 0 0 auto auto;
+}
diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json
index e4554570..2d4ca9ca 100644
--- a/frontend/app/src/assets/i18n/de.json
+++ b/frontend/app/src/assets/i18n/de.json
@@ -8,6 +8,9 @@
"export": "Exportieren",
"share": "Teilen",
"timeSuffix": "Uhr",
+ "ratings": {
+ "thank_you": "Vielen Dank für die Bewertung!"
+ },
"modal": {
"DISMISS_NEUTRAL": "Schließen",
"DISMISS_CANCEL": "Abbrechen",
diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json
index 664a7e34..3e5873ee 100644
--- a/frontend/app/src/assets/i18n/en.json
+++ b/frontend/app/src/assets/i18n/en.json
@@ -8,6 +8,9 @@
"export": "Export",
"share": "Share",
"timeSuffix": "",
+ "ratings": {
+ "thank_you": "Thank you for your feedback!"
+ },
"modal": {
"DISMISS_NEUTRAL": "Close",
"DISMISS_CANCEL": "Cancel",
diff --git a/frontend/app/src/assets/icons.min.woff2 b/frontend/app/src/assets/icons.min.woff2
index 81dd2c19..b7bfefc5 100644
Binary files a/frontend/app/src/assets/icons.min.woff2 and b/frontend/app/src/assets/icons.min.woff2 differ