mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 17:12:43 +00:00
feat: rating for dishes
This commit is contained in:
@@ -1,23 +1,23 @@
|
||||
<!--
|
||||
~ Copyright (C) 2022 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 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.
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
~ You should have received a copy of the GNU General Public License along with
|
||||
~ this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<stapps-data-list-item
|
||||
[item]="item"
|
||||
[hideThumbnail]="hideThumbnail"
|
||||
[lines]="'none'"
|
||||
[favoriteButton]="false"
|
||||
[listItemEndInteraction]="false"
|
||||
>
|
||||
<ng-template let-data>
|
||||
<stapps-assessment-list-item [item]="data"></stapps-assessment-list-item>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
frontend/app/src/app/modules/data/elements/rating.html
Normal file
36
frontend/app/src/app/modules/data/elements/rating.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<ion-button
|
||||
*ngIf="canBeRated"
|
||||
fill="clear"
|
||||
(click)="$event.stopPropagation(); rate = true"
|
||||
[disabled]="rated"
|
||||
>
|
||||
<ion-icon slot="icon-only" color="medium" name="thumbs_up_down"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<div class="rating-stars" *ngIf="rate && !rated" [@rating]="rating ? 'rated' : 'abandoned'">
|
||||
<ion-icon
|
||||
[class.rated-value]="rating === i"
|
||||
*ngFor="let i of [5, 4, 3, 2, 1]"
|
||||
(click)="$event.stopPropagation(); submitRating(i)"
|
||||
slot="icon-only"
|
||||
size="32"
|
||||
color="medium"
|
||||
name="grade"
|
||||
></ion-icon>
|
||||
<label class="thank-you">{{ 'ratings.thank_you' | translate }}</label>
|
||||
</div>
|
||||
66
frontend/app/src/app/modules/data/elements/rating.scss
Normal file
66
frontend/app/src/app/modules/data/elements/rating.scss
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class DataListItemComponent {
|
||||
*/
|
||||
@Input() item: SCThings;
|
||||
|
||||
@Input() favoriteButton = true;
|
||||
@Input() listItemEndInteraction = true;
|
||||
|
||||
@Input() lines = 'inset';
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
|
||||
<stapps-favorite-button *ngIf="favoriteButton" [item]="$any(item)"></stapps-favorite-button>
|
||||
<ng-container *ngIf="listItemEndInteraction" [ngSwitch]="item.type">
|
||||
<stapps-rating *ngSwitchCase="'dish'" [item]="$any(item)"></stapps-rating>
|
||||
<stapps-favorite-button *ngSwitchDefault [item]="$any(item)"></stapps-favorite-button>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #defaultContent>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
frontend/app/src/app/modules/data/rating.provider.ts
Normal file
125
frontend/app/src/app/modules/data/rating.provider.ts
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<SCUuid, true>;
|
||||
}
|
||||
|
||||
@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<SCUserGroup> {
|
||||
return this.settingsProvider
|
||||
.getSetting('profile', 'group')
|
||||
.then(it => (it as SCUserGroupSetting).value as SCUserGroup);
|
||||
}
|
||||
|
||||
private async getStoredRatings(): Promise<RatingStorage> {
|
||||
const has = await this.storageProvider.has(this.storageKey);
|
||||
const current = has
|
||||
? await this.storageProvider.get<RatingStorage | undefined>(this.storageKey)
|
||||
: undefined;
|
||||
const expired = current?.date !== this.today;
|
||||
return expired
|
||||
? await this.storageProvider.put<RatingStorage>(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<SCRatingResponse>(this.ratingRoute, undefined, request);
|
||||
|
||||
await this.markAsRated(uid);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async canRate(dish: SCDish): Promise<boolean> {
|
||||
const userGroup = (
|
||||
{
|
||||
students: 'student',
|
||||
employees: 'employee',
|
||||
guests: 'guest',
|
||||
} as Record<SCUserGroup, keyof SCAcademicPriceGroup>
|
||||
)[await this.userGroup];
|
||||
return dish.offers?.find(it => it.prices?.[userGroup]) !== undefined;
|
||||
}
|
||||
|
||||
async hasRated(uid: SCUuid): Promise<boolean> {
|
||||
const ratings = await this.getStoredRatings();
|
||||
return ratings.uuids[uid] ?? false;
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,5 @@
|
||||
[content]="'additives' | thingTranslate: item | join: ', '"
|
||||
>
|
||||
</stapps-simple-card>
|
||||
|
||||
<stapps-rating [item]="item"></stapps-rating>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"export": "Export",
|
||||
"share": "Share",
|
||||
"timeSuffix": "",
|
||||
"ratings": {
|
||||
"thank_you": "Thank you for your feedback!"
|
||||
},
|
||||
"modal": {
|
||||
"DISMISS_NEUTRAL": "Close",
|
||||
"DISMISS_CANCEL": "Cancel",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user