feat: rating for dishes

This commit is contained in:
2023-07-18 14:24:15 +02:00
parent 2fe8275f2f
commit c9240f289e
21 changed files with 534 additions and 31 deletions

View File

@@ -0,0 +1,67 @@
/*
* 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/>.
*/
describe('ratings', function () {
beforeEach(() => {
cy.intercept('https://mobile.server.uni-frankfurt.de/rating', {
body: {},
}).as('rating');
cy.intercept('POST', 'https://mobile.server.uni-frankfurt.de/search', {
fixture: 'search/types/canteen/canteen-1.json',
}).as('search');
cy.intercept('POST', 'https://mobile.server.uni-frankfurt.de/search/multi', {
fixture: 'search/types/dish/dish-1.json',
});
});
it('should open ratings', function () {
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('.rating-stars').should('not.exist');
cy.get('stapps-rating').click({scrollBehavior: 'center'});
cy.get('.rating-stars').should('exist');
});
it('should submit ratings', function () {
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('stapps-rating').click({scrollBehavior: 'center'});
cy.get('.rating-stars > ion-icon').first().click({scrollBehavior: 'center'});
cy.wait('@rating').its('request.body.rating').should('eq', 5);
});
it('should not be possible to rate twice', function () {
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('stapps-rating').click({scrollBehavior: 'center'});
cy.get('.rating-stars > ion-icon').first().click({scrollBehavior: 'center'});
cy.wait('@rating');
cy.get('stapps-rating ion-button').should('have.class', 'button-disabled');
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('stapps-rating ion-button').should('have.class', 'button-disabled');
});
it('should display a thank you message', function () {
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('stapps-rating').click({scrollBehavior: 'center'});
cy.get('.rating-stars > ion-icon').first().click({scrollBehavior: 'center'});
cy.wait('@rating');
cy.get('.thank-you').should('be.visible');
});
it('should be dismissible', function () {
cy.visit('/data-detail/86464b64-da1e-5578-a5c4-eec23457f596');
cy.get('stapps-rating').click({scrollBehavior: 'center'});
cy.get('.rating-stars').should('be.visible');
cy.get('body').click(0, 0);
cy.get('.rating-stars').should('not.exist');
});
});

View File

@@ -15,13 +15,13 @@
],
"scripts": {
"analyze": "webpack-bundle-analyzer www/stats.json",
"build": "ng build --configuration=production --stats-json && webpack-bundle-analyzer www/stats.json --mode static --report www/bundle-info.html",
"build": "pnpm check-icons && ng build --configuration=production --stats-json && webpack-bundle-analyzer www/stats.json --mode static --report www/bundle-info.html",
"build:analyze": "npm run build:stats && npm run analyze",
"build:android": "ionic capacitor build android --no-open && cd android && ./gradlew clean assembleDebug && cd ..",
"build:prod": "ng build --configuration=production",
"build:stats": "ng build --configuration=production --stats-json",
"changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0",
"check-icons": "ts-node scripts/check-icon-correctness.ts",
"check-icons": "ts-node-esm scripts/check-icon-correctness.ts",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"docker:build": "sudo docker run -p 8100:8100 -p 35729:35729 -p 53703:53703 -v $PWD:/app -it registry.gitlab.com/openstapps/app bash -c \"npm install && npm run build\"",
@@ -36,7 +36,7 @@
"licenses": "license-checker --json > src/assets/about/licenses.json && ts-node ./scripts/accumulate-licenses.ts && git add src/assets/about/licenses.json",
"lint": "ng lint",
"lint:fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/",
"minify-icons": "ts-node scripts/minify-icon-font.ts",
"minify-icons": "ts-node-esm scripts/minify-icon-font.ts",
"ng": "ng",
"postinstall": "(jetify && node ngcc-postinstall.mjs) || echo \"skipping jetify in production mode\"",
"preview": "http-server www --p 8101 -o",

View File

@@ -27,6 +27,7 @@ const modifiedFont = fontkit.openSync(config.outputPath);
let success = true;
// eslint-disable-next-line unicorn/prefer-top-level-await
checkAll().then(() => {
console.log();
if (success) {

View File

@@ -12,21 +12,16 @@
* 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 glob from 'glob';
import {glob} from 'glob';
import {readFileSync} from 'fs';
import {matchPropertyContent, matchTagProperties} from '../src/app/util/ion-icon/icon-match';
const globPromise = (pattern: string) =>
new Promise<string[]>((resolve, reject) =>
glob(pattern, (error, files) => (error ? reject(error) : resolve(files))),
);
/**
*
*/
export async function getUsedIconsHtml(glob = 'src/**/*.html'): Promise<Record<string, string[]>> {
export async function getUsedIconsHtml(pattern = 'src/**/*.html'): Promise<Record<string, string[]>> {
return Object.fromEntries(
(await globPromise(glob))
(await glob(pattern))
.map(file => [
file,
(readFileSync(file, 'utf8')
@@ -43,9 +38,9 @@ export async function getUsedIconsHtml(glob = 'src/**/*.html'): Promise<Record<s
/**
*
*/
export async function getUsedIconsTS(glob = 'src/**/*.ts'): Promise<Record<string, string[]>> {
export async function getUsedIconsTS(pattern = 'src/**/*.ts'): Promise<Record<string, string[]>> {
return Object.fromEntries(
(await globPromise(glob))
(await glob(pattern))
.map(file => [file, readFileSync(file, 'utf8').match(/(?<=Icon`)[\w-]+(?=`)/g) || []])
.filter(([, values]) => values.length > 0),
);

View File

@@ -115,6 +115,7 @@ async function minifyIconFont() {
);
}
// eslint-disable-next-line unicorn/prefer-top-level-await
minifyIconFont();
/**

View File

@@ -1,6 +1,7 @@
{
"extends": "../node_modules/@openstapps/configuration/tsconfig.json",
"extends": "@openstapps/tsconfig",
"compilerOptions": {
"lib": ["es2019"]
"module": "CommonJS",
"moduleResolution": "Node"
}
}

View File

@@ -17,7 +17,7 @@
[item]="item"
[hideThumbnail]="hideThumbnail"
[lines]="'none'"
[favoriteButton]="false"
[listItemEndInteraction]="false"
>
<ng-template let-data>
<stapps-assessment-list-item [item]="data"></stapps-assessment-list-item>

View File

@@ -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,

View File

@@ -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,
}),
]),
),
]),
]),
]),
]),
]);

View File

@@ -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;
}
}
}

View 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>

View 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;
}
}

View File

@@ -36,7 +36,7 @@ export class DataListItemComponent {
*/
@Input() item: SCThings;
@Input() favoriteButton = true;
@Input() listItemEndInteraction = true;
@Input() lines = 'inset';

View File

@@ -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>

View File

@@ -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 {

View 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;
}
}

View File

@@ -72,3 +72,5 @@
[content]="'additives' | thingTranslate: item | join: ', '"
>
</stapps-simple-card>
<stapps-rating [item]="item"></stapps-rating>

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -8,6 +8,9 @@
"export": "Export",
"share": "Share",
"timeSuffix": "",
"ratings": {
"thank_you": "Thank you for your feedback!"
},
"modal": {
"DISMISS_NEUTRAL": "Close",
"DISMISS_CANCEL": "Cancel",