diff --git a/AllTranslationAssets.ts b/AllTranslationAssets.ts index 78242e7..b695479 100644 --- a/AllTranslationAssets.ts +++ b/AllTranslationAssets.ts @@ -109,7 +109,8 @@ export default class AllTranslationAssets { saturday: new Translation( {"en":"Saturday","ca":"Dissabte","es":"Sábado","nl":"Zaterdag","fr":"Samedi"} ), sunday: new Translation( {"en":"Sunday","ca":"Diumenge","es":"Domingo","nl":"Zondag","fr":"Dimance"} ), }, - opening_hours: { open_during_ph: new Translation( {"nl":"Op een feestdag is deze zaak","ca":"Durant festes aquest servei és","es":"Durante fiestas este servicio está","en":"During a public holiday, this amenity is"} ), + opening_hours: { error_loading: new Translation( {"en":"Error: could not visualize these opening hours.","nl":"Sorry, deze openingsuren kunnen niet getoond worden"} ), + open_during_ph: new Translation( {"nl":"Op een feestdag is deze zaak","ca":"Durant festes aquest servei és","es":"Durante fiestas este servicio está","en":"During a public holiday, this amenity is"} ), opensAt: new Translation( {"en":"from","ca":"des de","es":"desde","nl":"vanaf"} ), openTill: new Translation( {"en":"till","ca":"fins","es":" hasta","nl":"tot"} ), not_all_rules_parsed: new Translation( {"en":"The opening hours of this shop are complicated. The following rules are ignored in the input element:","ca":"L'horari d'aquesta botiga és complicat. Les normes següents seran ignorades en l'entrada:","es":"El horario de esta tienda es complejo. Las normas siguientes serán ignoradas en la entrada:"} ), @@ -125,7 +126,16 @@ export default class AllTranslationAssets { reload: new Translation( {"en":"Reload the data","es":"Recargar datos","ca":"Recarregar dades","gl":"Recargar os datos","de":"Daten neu laden"} ), }, reviews: { title: new Translation( {"en":"{count} reviews","nl":"{count} beoordelingen"} ), + name_required: new Translation( {"en":"A name is required in order to display and create reviews","nl":"De naam van dit object moet gekend zijn om een review te kunnen maken"} ), no_reviews_yet: new Translation( {"en":"There are no reviews yet. Be the first to write one and help open data and the business!","nl":"Er zijn nog geen beoordelingen. Wees de eerste om een beoordeling te schrijven en help open data en het bedrijf"} ), + write_a_comment: new Translation( {"en":"Leave a review...","nl":"Schrijf een beoordeling..."} ), + no_rating: new Translation( {"en":"No rating given","nl":"Geen score bekend"} ), + posting_as: new Translation( {"en":"Posting as","nl":"Ingelogd als"} ), + i_am_affiliated: new Translation( {"en":"I am affiliated with this object
Check if you are the owner, creator, employee, ... or similar","nl":"
I am affiliated with this object
Vink aan indien je de oprichter, maker, werknemer, ... of dergelijke bent
"} ), + affiliated_reviewer_warning: new Translation( {"en":"(Affiliated review)","nl":"(Review door betrokkene)"} ), + saving_review: new Translation( {"en":"Saving...","nl":"Opslaan..."} ), + saved: new Translation( {"en":"Review saved. Thanks for sharing!","nl":"Bedankt om je beoordeling te delen!"} ), attribution: new Translation( {"en":"Reviews are powered by Mangrove Reviews and are available under CC-BY 4.0","nl":"De beoordelingen worden voorzien door Mangrove Reviews en zijn beschikbaar onder deCC-BY 4.0-licentie "} ), + plz_login: new Translation( {"en":"Login to leave a review","nl":"Meld je aan om een beoordeling te geven"} ), }, }} \ No newline at end of file diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index da621a1..6c6298f 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -93,6 +93,12 @@ export default class LayerConfig { return tagRenderings.map( (renderingJson, i) => { if (typeof renderingJson === "string") { + + if(renderingJson === "questions"){ + return new TagRenderingConfig("questions") + } + + const shared = SharedTagRenderings.SharedTagRendering[renderingJson]; if (shared !== undefined) { return shared; diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index 28f5fb8..da3747b 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -143,6 +143,10 @@ export interface LayerConfigJson { * Note that we can also use a string here - where the string refers to a tagrenering defined in `assets/questions/questions.json`, * where a few very general questions are defined e.g. website, phone number, ... * + * A special value is 'questions', which indicates the location of the questions box. If not specified, it'll be appended to the bottom of the featureInfobox. + * */ tagRenderings?: (string | TagRenderingConfigJson) [] + + } \ No newline at end of file diff --git a/Customizations/JSON/TagRenderingConfig.ts b/Customizations/JSON/TagRenderingConfig.ts index d6e9a92..53ff1dc 100644 --- a/Customizations/JSON/TagRenderingConfig.ts +++ b/Customizations/JSON/TagRenderingConfig.ts @@ -31,8 +31,15 @@ export default class TagRenderingConfig { constructor(json: string | TagRenderingConfigJson, context?: string) { - if(json === undefined){ - throw "Initing a TagRenderingConfig with undefined in "+context; + if (json === "questions") { + // Very special value + this.render = null; + this.question = null; + this.condition = null; + } + + if (json === undefined) { + throw "Initing a TagRenderingConfig with undefined in " + context; } if (typeof json === "string") { this.render = Translations.T(json); @@ -63,13 +70,13 @@ export default class TagRenderingConfig { throw "Invalid mapping: if without body" } let hideInAnswer : boolean | TagsFilter = false; - if(typeof mapping.hideInAnswer === "boolean"){ + if (typeof mapping.hideInAnswer === "boolean") { hideInAnswer = mapping.hideInAnswer; - }else{ - hideInAnswer = FromJSON.Tag(mapping.hideInAnswer); + } else if (mapping.hideInAnswer !== undefined) { + hideInAnswer = FromJSON.Tag(mapping.hideInAnswer, `${context}.mapping[${i}].hideInAnswer`); } return { - if: FromJSON.Tag(mapping.if, `${context}.mapping[${i}]`), + if: FromJSON.Tag(mapping.if, `${context}.mapping[${i}].if`), then: Translations.T(mapping.then), hideInAnswer: hideInAnswer }; diff --git a/Customizations/SharedTagRenderings.ts b/Customizations/SharedTagRenderings.ts index c6dae7d..772bd97 100644 --- a/Customizations/SharedTagRenderings.ts +++ b/Customizations/SharedTagRenderings.ts @@ -10,12 +10,11 @@ export default class SharedTagRenderings { private static generatedSharedFields(iconsOnly = false) { const dict = {} - function add(key, store) { try { - dict[key] = new TagRenderingConfig(store[key]) + dict[key] = new TagRenderingConfig(store[key], key) } catch (e) { - console.error("BUG: could not parse", key, " from questions.json or icons.json", e) + console.error("BUG: could not parse", key, " from questions.json or icons.json - this error happened during the build step of the SharedTagRenderings", e) } } diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index 047198e..572ed63 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -24,7 +24,7 @@ export class OsmConnection { public auth; public userDetails: UIEventSource; - private _dryRun: boolean; + _dryRun: boolean; public preferencesHandler: OsmPreferences; public changesetHandler: ChangesetHandler; diff --git a/Logic/Web/MangroveReviews.ts b/Logic/Web/MangroveReviews.ts index 80b6a44..2e6ec4c 100644 --- a/Logic/Web/MangroveReviews.ts +++ b/Logic/Web/MangroveReviews.ts @@ -1,75 +1,158 @@ import * as mangrove from 'mangrove-reviews' import {UIEventSource} from "../UIEventSource"; +import {Review} from "./Review"; + +export class MangroveIdentity { + private readonly _mangroveIdentity: UIEventSource; + public keypair: any = undefined; + + constructor(mangroveIdentity: UIEventSource) { + const self = this; + this._mangroveIdentity = mangroveIdentity; + mangroveIdentity.addCallbackAndRun(str => { + if (str === undefined || str === "") { + return; + } + mangrove.jwkToKeypair(JSON.parse(str)).then(keypair => { + self.keypair = keypair; + console.log("Identity loaded") + }) + }) + if ((mangroveIdentity.data ?? "") === "") { + this.CreateIdentity(); + } + } + + /** + * Creates an identity if none exists already. + * Is written into the UIEventsource, which was passed into the constructor + * @constructor + */ + private CreateIdentity() { + if ("" !== (this._mangroveIdentity.data ?? "")) { + throw "Identity already defined - not creating a new one" + } + const self = this; + mangrove.generateKeypair().then( + keypair => { + self.keypair = keypair; + mangrove.keypairToJwk(keypair).then(jwk => { + self._mangroveIdentity.setData(JSON.stringify(jwk)); + }) + }); + } + +} export default class MangroveReviews { + private readonly _lon: number; + private readonly _lat: number; + private readonly _name: string; + private readonly _reviews: UIEventSource = new UIEventSource([]); + private _dryRun: boolean; + private _mangroveIdentity: MangroveIdentity; + private _lastUpdate : Date = undefined; - constructor() { + private static _reviewsCache = {}; + + public static Get(lon: number, lat: number, name: string, + identity: MangroveIdentity, + dryRun?: boolean){ + const newReviews = new MangroveReviews(lon, lat, name, identity, dryRun); + + const uri = newReviews.GetSubjectUri(); + const cached = MangroveReviews._reviewsCache[uri]; + if(cached !== undefined){ + return cached; + } + MangroveReviews._reviewsCache[uri] = newReviews; + + return newReviews; } + + private constructor(lon: number, lat: number, name: string, + identity: MangroveIdentity, + dryRun?: boolean) { + + this._lon = lon; + this._lat = lat; + this._name = name; + this._mangroveIdentity = identity; + this._dryRun = dryRun; + + } + + /** + * Gets an URI which represents the item in a mangrove-compatible way + * @constructor + */ + public GetSubjectUri() { + let uri = `geo:${this._lat},${this._lon}?u=50`; + if (this._name !== undefined && this._name !== null) { + uri += "&q=" + this._name; + } + return uri; + } + /** * Gives a UIEVentsource with all reviews. * Note: rating is between 1 and 100 */ - public static GetReviewsFor(lon: number, lat: number, name: string): UIEventSource<{ - comment?: string, - author: string, - date: Date, - rating: number - }[]> { - - let uri = `geo:${lat},${lon}?u=50`; - if (name !== undefined && name !== null) { - uri += "&q=" + name; - } - const reviewsSource : UIEventSource< { - comment?: string, - author: string, - date: Date, - rating: number - }[]> = new UIEventSource([]); + public GetReviews(): UIEventSource { - mangrove.getReviews({sub: uri}).then( + if(this._lastUpdate !== undefined && this._reviews.data !== undefined && + (new Date().getTime() - this._lastUpdate.getTime()) < 15000 + ){ + // Last update was pretty recent + return this._reviews; + } + this._lastUpdate = new Date(); + + const self = this; + mangrove.getReviews({sub: this.GetSubjectUri()}).then( (data) => { - const reviews = [{ - date: new Date(), - comment: "Short", - rating: 1, - author: "Troll" - },{ - date: new Date(), - comment: "Not good", - rating: 10, - author: "Troll" - },{ - date: new Date(), - comment: "Not soo good", - rating: 20, - author: "Troll" - },{ - date: new Date(), - comment: "Meh", - rating: 30, - author: "Troll" - }, - { - date: new Date(), - comment: "Lorum ipsum lorem qsmldkfj qsdfmqmsd qmlsdmlkjazmeliq dmqlsdkf amldkfjqmlskdbmaize qsmdl fka mqlsnkd azie qmxbilqmslef amlzdf qsmdlfk afdml kqbnqsdlkf m", - rating: 50, - author: "Troll" - }]; + const reviews = []; for (const review of data.reviews) { const r = review.payload; reviews.push({ date: new Date(r.iat * 1000), comment: r.opinion, author: r.metadata.nickname, + affiliated: r.metadata.is_affiliated, rating: r.rating // percentage points }) } - reviewsSource.setData(reviews) + self._reviews.setData(reviews) } ); - return reviewsSource; + return this._reviews; + } + + AddReview(r: Review, callback?: (() => void)) { + + + callback = callback ?? (() => { + return undefined; + }); + + const payload = { + sub: this.GetSubjectUri(), + rating: r.rating, + opinion: r.comment, + metadata: { + is_affiliated: r.affiliated, + nickname: r.author, + } + }; + if (this._dryRun) { + console.log("DRYRUNNING mangrove reviews: ", payload); + } else { + mangrove.signAndSubmitReview(this._mangroveIdentity.keypair, payload).then(callback) + } + this._reviews.data.push(r); + this._reviews.ping(); + callback(); } diff --git a/Logic/Web/Review.ts b/Logic/Web/Review.ts new file mode 100644 index 0000000..24f397a --- /dev/null +++ b/Logic/Web/Review.ts @@ -0,0 +1,7 @@ +export interface Review { + comment?: string, + author: string, + date: Date, + rating: number, + affiliated: boolean +} \ No newline at end of file diff --git a/State.ts b/State.ts index 72b7a83..d2864cf 100644 --- a/State.ts +++ b/State.ts @@ -13,6 +13,7 @@ import {QueryParameters} from "./Logic/Web/QueryParameters"; import {BaseLayer} from "./Logic/BaseLayer"; import LayoutConfig from "./Customizations/JSON/LayoutConfig"; import Hash from "./Logic/Web/Hash"; +import {MangroveIdentity} from "./Logic/Web/MangroveReviews"; /** * Contains the global state: a bunch of UI-event sources @@ -64,13 +65,15 @@ export default class State { */ public osmConnection: OsmConnection; + public mangroveIdentity: MangroveIdentity; + public favouriteLayers: UIEventSource; public layerUpdater: UpdateFromOverpass; public filteredLayers: UIEventSource = new UIEventSource([]) - + /** * The message that should be shown at the center of the screen */ @@ -209,6 +212,10 @@ export default class State { true ); + this.mangroveIdentity = new MangroveIdentity( + this.osmConnection.GetLongPreference("identity", "mangrove") + ); + const h = Hash.Get(); this.selectedElement.addCallback(selected => { diff --git a/Svg.ts b/Svg.ts index 5b6bafd..b6a57e2 100644 --- a/Svg.ts +++ b/Svg.ts @@ -244,6 +244,16 @@ export default class Svg { public static star_half_svg() { return new FixedUiElement(Svg.star_half);} public static star_half_ui() { return new FixedUiElement(Svg.star_half_img);} + public static star_outline = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " + public static star_outline_img = Img.AsImageElement(Svg.star_outline) + public static star_outline_svg() { return new FixedUiElement(Svg.star_outline);} + public static star_outline_ui() { return new FixedUiElement(Svg.star_outline_img);} + + public static star_outline_half = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " + public static star_outline_half_img = Img.AsImageElement(Svg.star_outline_half) + public static star_outline_half_svg() { return new FixedUiElement(Svg.star_outline_half);} + public static star_outline_half_ui() { return new FixedUiElement(Svg.star_outline_half_img);} + public static statistics = " Svg Vector Icons : http://www.onlinewebfonts.com/icon " public static statistics_img = Img.AsImageElement(Svg.statistics) public static statistics_svg() { return new FixedUiElement(Svg.statistics);} @@ -269,4 +279,4 @@ export default class Svg { public static wikipedia_svg() { return new FixedUiElement(Svg.wikipedia);} public static wikipedia_ui() { return new FixedUiElement(Svg.wikipedia_img);} -public static All = {"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"down.svg": Svg.down,"envelope.svg": Svg.envelope,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapillary.svg": Svg.mapillary,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"statistics.svg": Svg.statistics,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} +public static All = {"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"down.svg": Svg.down,"envelope.svg": Svg.envelope,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapillary.svg": Svg.mapillary,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"star_outline.svg": Svg.star_outline,"star_outline_half.svg": Svg.star_outline_half,"statistics.svg": Svg.statistics,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} diff --git a/UI/CustomGenerator/SettingsTable.ts b/UI/CustomGenerator/SettingsTable.ts index f16fbce..28bb4bc 100644 --- a/UI/CustomGenerator/SettingsTable.ts +++ b/UI/CustomGenerator/SettingsTable.ts @@ -13,7 +13,7 @@ export default class SettingsTable extends UIElement { public selectedSetting: UIEventSource>; constructor(elements: (SingleSetting | string)[], - currentSelectedSetting: UIEventSource>) { + currentSelectedSetting?: UIEventSource>) { super(undefined); const self = this; this.selectedSetting = currentSelectedSetting ?? new UIEventSource>(undefined); diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index 0cdf038..9b82ce3 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -63,11 +63,11 @@ export class TextField extends InputElement { InnerRender(): string { + const placeholder = this._placeholder.InnerRender().replace("'", "'"); if (this._htmlType === "area") { - return `` + return `` } - const placeholder = this._placeholder.InnerRender().replace("'", "'"); let label = ""; if (this._label != undefined) { label = this._label.Render(); diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index 5fbfaf6..5d0e3a4 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -158,7 +158,6 @@ export default class ValidatedTextField { if (str === undefined) { return false; } - console.log("Validating phone number",str,"in country",country()) return parsePhoneNumberFromString(str, (country())?.toUpperCase() as any)?.isValid() ?? false }, (str, country: () => string) => parsePhoneNumberFromString(str, (country())?.toUpperCase() as any).formatInternational() diff --git a/UI/OhVisualization.ts b/UI/OhVisualization.ts index 8bad043..e665539 100644 --- a/UI/OhVisualization.ts +++ b/UI/OhVisualization.ts @@ -5,6 +5,7 @@ import Combine from "./Base/Combine"; import Translations from "./i18n/Translations"; import {FixedUiElement} from "./Base/FixedUiElement"; import {OH} from "../Logic/OpeningHours"; +import State from "../State"; export default class OpeningHoursVisualization extends UIElement { private readonly _key: string; @@ -165,7 +166,12 @@ export default class OpeningHoursVisualization extends UIElement { }, {tag_key: this._key}); } catch (e) { console.log(e); - return `Error: could not visualize these opening hours
${e}` + const msg = new Combine([Translations.t.general.opening_hours.error_loading, + State.state?.osmConnection?.userDetails?.data?.csCount >= State.userJourney.tagsVisibleAndWikiLinked ? + `${e}` + : "" + ]); + return msg.Render(); } if (!oh.getState() && !oh.getUnknown()) { diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index 03eb19f..c714e5a 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -35,11 +35,25 @@ export class FeatureInfoBox extends UIElement { this._titleIcons = new Combine( layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon))) .SetClass("featureinfobox-icons"); - this._renderings = layerConfig.tagRenderings.map(tr => new EditableTagRendering(tags, tr)); - this._renderings[0]?.SetClass("first-rendering"); + + let questionBox : UIElement = undefined; if (State.state.featureSwitchUserbadge.data) { - this._questionBox = new QuestionBox(tags, layerConfig.tagRenderings); + questionBox = new QuestionBox(tags, layerConfig.tagRenderings); } + + let questionBoxIsUsed = false; + this._renderings = layerConfig.tagRenderings.map(tr => { + if(tr.question === null){ + questionBoxIsUsed = true; + // This is the question box! + return questionBox; + } + return new EditableTagRendering(tags, tr); + }); + this._renderings[0]?.SetClass("first-rendering"); + if(!questionBoxIsUsed){ + this._renderings.push(questionBox); + } } InnerRender(): string { diff --git a/UI/Popup/QuestionBox.ts b/UI/Popup/QuestionBox.ts index 67d1775..e6caecb 100644 --- a/UI/Popup/QuestionBox.ts +++ b/UI/Popup/QuestionBox.ts @@ -22,7 +22,9 @@ export default class QuestionBox extends UIElement { this.ListenTo(this._skippedQuestions); this._tags = tags; const self = this; - this._tagRenderings = tagRenderings.filter(tr => tr.question !== undefined); + this._tagRenderings = tagRenderings + .filter(tr => tr.question !== undefined) + .filter(tr => tr.question !== null); this._tagRenderingQuestions = this._tagRenderings .map((tagRendering, i) => new TagRenderingQuestion(this._tags, tagRendering, () => { diff --git a/UI/Popup/SaveButton.ts b/UI/Popup/SaveButton.ts index 52a78de..2886d9d 100644 --- a/UI/Popup/SaveButton.ts +++ b/UI/Popup/SaveButton.ts @@ -1,32 +1,33 @@ import {UIEventSource} from "../../Logic/UIEventSource"; import {UIElement} from "../UIElement"; import Translations from "../i18n/Translations"; -import State from "../../State"; +import {OsmConnection, UserDetails} from "../../Logic/Osm/OsmConnection"; export class SaveButton extends UIElement { - private _value: UIEventSource; - private _friendlyLogin: UIElement; + private readonly _value: UIEventSource; + private readonly _friendlyLogin: UIElement; + private readonly _userDetails: UIEventSource; - constructor(value: UIEventSource) { + constructor(value: UIEventSource, osmConnection: OsmConnection) { super(value); + this._userDetails = osmConnection?.userDetails; if(value === undefined){ throw "No event source for savebutton, something is wrong" } this._value = value; - this._friendlyLogin = Translations.t.general.loginToStart.Clone() .SetClass("login-button-friendly") - .onClick(() => State.state.osmConnection.AttemptLogin()) + .onClick(() => osmConnection?.AttemptLogin()) } InnerRender(): string { let clss = "save"; - if(State.state !== undefined && !State.state.osmConnection.userDetails.data.loggedIn){ + if(this._userDetails != undefined && !this._userDetails.data.loggedIn){ return this._friendlyLogin.Render(); } - if ((this._value.data ?? "") === "") { + if (this._value.data === false || (this._value.data ?? "") === "") { clss = "save-non-active"; } return Translations.t.general.save.Clone().SetClass(clss).Render(); diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 1cdd805..36f152f 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -64,7 +64,7 @@ export default class TagRenderingQuestion extends UIElement { } - this._saveButton = new SaveButton(this._inputElement.GetValue()) + this._saveButton = new SaveButton(this._inputElement.GetValue(), State.state.osmConnection) .onClick(save) diff --git a/UI/ReviewElement.ts b/UI/Reviews/ReviewElement.ts similarity index 60% rename from UI/ReviewElement.ts rename to UI/Reviews/ReviewElement.ts index c908cbe..2ed01bd 100644 --- a/UI/ReviewElement.ts +++ b/UI/Reviews/ReviewElement.ts @@ -1,29 +1,38 @@ -import {UIElement} from "./UIElement"; -import {UIEventSource} from "../Logic/UIEventSource"; -import Translations from "./i18n/Translations"; -import Combine from "./Base/Combine"; -import {FixedUiElement} from "./Base/FixedUiElement"; -import {Utils} from "../Utils"; - /** * Shows the reviews and scoring base on mangrove.reviesw */ -export default class ReviewElement extends UIElement { - private _reviews: UIEventSource<{ comment?: string; author: string; date: Date; rating: number }[]>; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {Review} from "../../Logic/Web/Review"; +import {UIElement} from "../UIElement"; +import {Utils} from "../../Utils"; +import Combine from "../Base/Combine"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import Translations from "../i18n/Translations"; - constructor(reviews: UIEventSource<{ - comment?: string, - author: string, - date: Date, - rating: number - }[]>) { +export default class ReviewElement extends UIElement { + private readonly _reviews: UIEventSource; + private readonly _subject: string; + private _middleElement: UIElement; + + constructor(subject: string, reviews: UIEventSource, middleElement: UIElement) { super(reviews); + this._middleElement = middleElement; + if(reviews === undefined){ + throw "No reviews UIEVentsource Given!" + } this._reviews = reviews; + this._subject = subject; } InnerRender(): string { function genStars(rating: number) { + if(rating === undefined){ + return Translations.t.reviews.no_rating; + } + if(rating < 10){ + rating = 10; + } const scoreTen = Math.round(rating / 10); return new Combine([ "".repeat(Math.floor(scoreTen / 2)), @@ -38,11 +47,15 @@ export default class ReviewElement extends UIElement { elements.push( new Combine([ genStars(avg).SetClass("stars"), + ``, Translations.t.reviews.title - .Subs({count: "" + revs.length}) + .Subs({count: "" + revs.length}), + "" ]) .SetClass("review-title")); + + elements.push(this._middleElement); elements.push(...revs.map(review => { const d = review.date; @@ -55,8 +68,11 @@ export default class ReviewElement extends UIElement { ]).SetClass("review-stars-comment"), new Combine([ + new Combine([ - new FixedUiElement(review.author).SetClass("review-author"), + new FixedUiElement(review.author).SetClass("review-author"), + review.affiliated ? Translations.t.reviews.affiliated_reviewer_warning : "", + ]).SetStyle("margin-right: 0.5em"), new FixedUiElement(`${d.getFullYear()}-${Utils.TwoDigits(d.getMonth() + 1)}-${Utils.TwoDigits(d.getDate())} ${Utils.TwoDigits(d.getHours())}:${Utils.TwoDigits(d.getMinutes())}`) .SetClass("review-date") ]).SetClass("review-author-date") diff --git a/UI/Reviews/ReviewForm.ts b/UI/Reviews/ReviewForm.ts new file mode 100644 index 0000000..417d44e --- /dev/null +++ b/UI/Reviews/ReviewForm.ts @@ -0,0 +1,116 @@ +import {UIElement} from "../UIElement"; +import {InputElement} from "../Input/InputElement"; +import {Review} from "../../Logic/Web/Review"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {TextField} from "../Input/TextField"; +import Translations from "../i18n/Translations"; +import Combine from "../Base/Combine"; +import Svg from "../../Svg"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import {SaveButton} from "../Popup/SaveButton"; +import CheckBoxes from "../Input/Checkboxes"; +import {UserDetails} from "../../Logic/Osm/OsmConnection"; + +export default class ReviewForm extends InputElement { + + private readonly _value: UIEventSource; + private readonly _comment: UIElement; + private readonly _stars: UIElement; + private _saveButton: UIElement; + private readonly _isAffiliated: UIElement; + private userDetails: UIEventSource; + private readonly _postingAs: UIElement; + + + constructor(onSave: ((r: Review, doneSaving: (() => void)) => void), userDetails: UIEventSource) { + super(); + this.userDetails = userDetails; + const t = Translations.t.reviews; + this._value = new UIEventSource({ + rating: undefined, + comment: undefined, + author: userDetails.data.name, + affiliated: false, + date: new Date() + }); + const comment = new TextField({ + placeholder: Translations.t.reviews.write_a_comment, + textArea: true, + value: this._value.map(r => r?.comment), + textAreaRows: 5 + }) + comment.GetValue().addCallback(comment => { + self._value.data.comment = comment; + self._value.ping(); + }) + const self = this; + + this._postingAs = + new Combine([t.posting_as, new VariableUiElement(userDetails.map((ud: UserDetails) => ud.name)).SetClass("review-author")]) + .SetStyle("display:flex;flex-direction: column;align-items: flex-end;margin-right: 0.5;") + this._saveButton = + new SaveButton(this._value.map(r => self.IsValid(r)), undefined) + .onClick(() => { + self._saveButton = Translations.t.reviews.saving_review; + onSave(this._value.data, () => { + self._saveButton = Translations.t.reviews.saved; + }); + }) + + this._isAffiliated = new CheckBoxes([t.i_am_affiliated]).SetStyle(" display:inline-block;") + + this._comment = comment; + const stars = [] + for (let i = 1; i <= 5; i++) { + stars.push( + new VariableUiElement(this._value.map(review => { + if (review.rating === undefined) { + return Svg.star_outline.replace(/#000000/g, "#ccc"); + } + return review.rating < i * 20 ? + Svg.star_outline : + Svg.star + } + )) + .onClick(() => { + self._value.data.rating = i * 20; + self._value.ping(); + }) + ) + } + this._stars = new Combine(stars).SetClass("review-form-rating") + } + + GetValue(): UIEventSource { + return this._value; + } + + InnerRender(): string { + + if(!this.userDetails.data.loggedIn){ + return Translations.t.reviews.plz_login.Render(); + } + + return new Combine([ + new Combine([this._stars, this._postingAs]).SetClass("review-form-top"), + this._comment, + new Combine([ + this._isAffiliated, + this._saveButton + ]).SetClass("review-form-bottom") + ]) + .SetClass("review-form") + .Render(); + } + + IsSelected: UIEventSource = new UIEventSource(false); + + IsValid(r: Review): boolean { + if (r === undefined) { + return false; + } + return (r.comment?.length ?? 0) <= 1000 && (r.author?.length ?? 0) <= 20 && r.rating >= 0 && r.rating <= 100; + } + + +} \ No newline at end of file diff --git a/UI/Reviews/ReviewPanel.ts b/UI/Reviews/ReviewPanel.ts new file mode 100644 index 0000000..9ad64b7 --- /dev/null +++ b/UI/Reviews/ReviewPanel.ts @@ -0,0 +1,11 @@ +import {UIElement} from "../UIElement"; + +export default class ReviewPanel extends UIElement { + + + + InnerRender(): string { + return ""; + } + +} \ No newline at end of file diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 260786c..1b45a32 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -12,6 +12,10 @@ import {Translation} from "./i18n/Translation"; import State from "../State"; import ShareButton from "./ShareButton"; import Svg from "../Svg"; +import ReviewElement from "./Reviews/ReviewElement"; +import MangroveReviews from "../Logic/Web/MangroveReviews"; +import Translations from "./i18n/Translations"; +import ReviewForm from "./Reviews/ReviewForm"; export class SubstitutedTranslation extends UIElement { private readonly tags: UIEventSource; @@ -119,6 +123,7 @@ export default class SpecialVisualizations { })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;") }) }, + { funcName: "image_carousel", docs: "Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)", @@ -149,6 +154,24 @@ export default class SpecialVisualizations { return new ImageUploadFlow(tags, args[0]) } }, + + { + funcName: "reviews", + docs: "Adds an overview of the mangrove-reviews of this object. IMPORTANT: the _name_ of the object should be defined for this to work!", + args: [], + constr: (tags, args) => { + const tgs = tags.data; + if (tgs.name === undefined || tgs.name === "") { + return Translations.t.reviews.name_required; + } + const mangrove = MangroveReviews.Get(Number(tgs._lon), Number(tgs._lat), tgs.name, + State.state.mangroveIdentity, + State.state.osmConnection._dryRun + ); + const form = new ReviewForm(r => mangrove.AddReview(r), State.state.osmConnection.userDetails); + return new ReviewElement(mangrove.GetSubjectUri(), mangrove.GetReviews(), form); + } + }, { funcName: "opening_hours_table", docs: "Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag 'opening_hours'.", diff --git a/assets/svg/star_outline.svg b/assets/svg/star_outline.svg new file mode 100644 index 0000000..df40f26 --- /dev/null +++ b/assets/svg/star_outline.svg @@ -0,0 +1,57 @@ + + + + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + image/svg+xml + + + + + + diff --git a/assets/svg/star_outline_half.svg b/assets/svg/star_outline_half.svg new file mode 100644 index 0000000..d233cc2 --- /dev/null +++ b/assets/svg/star_outline_half.svg @@ -0,0 +1,63 @@ + + + + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + image/svg+xml + + + + + + + diff --git a/assets/tagRenderings/questions.json b/assets/tagRenderings/questions.json index d0591b1..33086aa 100644 --- a/assets/tagRenderings/questions.json +++ b/assets/tagRenderings/questions.json @@ -2,6 +2,9 @@ "images": { "render": "{image_carousel()}{image_upload()}" }, + "reviews": { + "render": "{reviews()}" + }, "phone": { "question": { "en": "What is the phone number of {name}?", diff --git a/assets/themes/shops/shops.json b/assets/themes/shops/shops.json index 0b27f3f..29068dc 100644 --- a/assets/themes/shops/shops.json +++ b/assets/themes/shops/shops.json @@ -67,9 +67,6 @@ } ] }, - "titleIcons": [ - "isOpen" - ], "description": { "en": "A shop", "fr": "Un magasin" @@ -233,7 +230,9 @@ "key": "opening_hours", "type": "opening_hours" } - } + }, + "questions", + "reviews" ], "hideUnderlayingFeaturesMinPercentage": 0, "icon": { diff --git a/assets/translations.json b/assets/translations.json index 779aa05..a660e54 100644 --- a/assets/translations.json +++ b/assets/translations.json @@ -830,6 +830,10 @@ } }, "opening_hours": { + "error_loading": { + "en": "Error: could not visualize these opening hours.", + "nl": "Sorry, deze openingsuren kunnen niet getoond worden" + }, "open_during_ph": { "nl": "Op een feestdag is deze zaak", "ca": "Durant festes aquest servei és", @@ -913,13 +917,49 @@ "en": "{count} reviews", "nl": "{count} beoordelingen" }, + "name_required": { + "en": "A name is required in order to display and create reviews", + "nl": "De naam van dit object moet gekend zijn om een review te kunnen maken" + }, "no_reviews_yet": { "en": "There are no reviews yet. Be the first to write one and help open data and the business!", "nl": "Er zijn nog geen beoordelingen. Wees de eerste om een beoordeling te schrijven en help open data en het bedrijf" }, + "write_a_comment": { + "en": "Leave a review...", + "nl": "Schrijf een beoordeling..." + }, + "no_rating": { + "en": "No rating given", + "nl": "Geen score bekend" + }, + "posting_as": { + "en": "Posting as", + "nl": "Ingelogd als" + }, + "i_am_affiliated": { + "en": "I am affiliated with this object
Check if you are an owner, creator, employee, ...", + "nl": "
I am affiliated with this object
Vink aan indien je de oprichter, maker, werknemer, ... of dergelijke bent
" + }, + "affiliated_reviewer_warning": { + "en": "(Affiliated review)", + "nl": "(Review door betrokkene)" + }, + "saving_review": { + "en": "Saving...", + "nl": "Opslaan..." + }, + "saved": { + "en": "Review saved. Thanks for sharing!", + "nl": "Bedankt om je beoordeling te delen!" + }, "attribution": { "en": "Reviews are powered by Mangrove Reviews and are available under CC-BY 4.0", "nl": "De beoordelingen worden voorzien door Mangrove Reviews en zijn beschikbaar onder deCC-BY 4.0-licentie " + }, + "plz_login": { + "en": "Login to leave a review", + "nl": "Meld je aan om een beoordeling te geven" } } } \ No newline at end of file diff --git a/css/ReviewElement.css b/css/ReviewElement.css index 4d2a1dd..7e04f34 100644 --- a/css/ReviewElement.css +++ b/css/ReviewElement.css @@ -1,3 +1,8 @@ +.review { + display: block; + margin-top: 1em; +} + .review-title { font-size: x-large; display: flex; @@ -39,7 +44,6 @@ .review-author { font-weight: bold; - margin-right: 0.5em; } .review-author-date { @@ -66,7 +70,7 @@ } .review-attribution span { - width: calc(65% - 3em); + width: calc(75% - 3em); text-align: right; max-width: 20em; } @@ -76,3 +80,45 @@ margin-left: 0.5em; } +.review-form { + display: block; + border-radius: 1em; + padding: 1em; + background-color: var(--subtle-detail-color); + color: var(--subtle-detail-color-contrast); + border: 2px solid var(--subtle-detail-color-contrast) +} + +.review-form-bottom { + display: flex; + justify-content: space-between; + margin-top: 0.5em; +} + +.review-form-top { + display: flex; + justify-content: space-between; +} + + +.review-form-rating { +} + +.review-form .save { + display: block ruby; +} + +.review-form .save-non-active { + display: block ruby; +} +.review-form textarea { + resize: unset; +} + +.review-form-rating svg { + width: 2em; + height: 2em; + margin: 0; + padding: 0; + display: inline-block; +} \ No newline at end of file diff --git a/test.ts b/test.ts index 31382ee..2c47420 100644 --- a/test.ts +++ b/test.ts @@ -1,50 +1,54 @@ //* import MangroveReviews from "./Logic/Web/MangroveReviews"; -import ReviewElement from "./UI/ReviewElement"; +import ReviewElement from "./UI/Reviews/ReviewElement"; +import {UIEventSource} from "./Logic/UIEventSource"; +import ReviewForm from "./UI/Reviews/ReviewForm"; +import Combine from "./UI/Base/Combine"; +import {FixedUiElement} from "./UI/Base/FixedUiElement"; -const review = MangroveReviews.GetReviewsFor(3.22000, 51.21576, "Pietervdvn Software Consultancy") -new ReviewElement(review).AttachTo("maindiv"); -/* -mangrove.getReviews({sub: 'geo:,?q=&u=15'}).then( - (data) => { - for (const review of data.reviews) { - console.log(review.payload); - // .signature - // .kid - // .jwt - } - } -);*/ +const identity = '{"crv":"P-256","d":"6NHPmTFRedjNl-ZfLRAXhOaNKtRR9GYzPHsO1CzN5wQ","ext":true,"key_ops":["sign"],"kty":"EC","x":"Thm_pL5m0m9Jl41z9vgMTHNyja-9H58v0stJWT4KhTI","y":"PjBldCW85b8K6jEZbw0c2UZskpo-rrkwfPnD7s1MXSM","metadata":"Mangrove private key"}' + +const mangroveReviews = new MangroveReviews(0, 0, "Null Island", + new UIEventSource(identity), true) + +new ReviewElement(mangroveReviews.GetSubjectUri(), mangroveReviews.GetReviews()).AttachTo("maindiv"); +const form = new ReviewForm((r,done) => { + mangroveReviews.AddReview(r, done); +}); +form.AttachTo("extradiv") + +form.GetValue().map(r => form.IsValid(r)).addCallback(d => console.log(d)) /* -mangrove.generateKeypair().then( - keypair => { - mangrove.keypairToJwk(keypair).then(jwk => { - console.log(jwk) - // const restoredKeypair = await mangrove.jwkToKeypair(jwk). -// Sign and submit a review (reviews of this example subject are removed from the database). - mangrove.signAndSubmitReview(keypair, { - // Lat,lon! - sub: "geo:51.21576,3.22000?q=Pietervdvn Software Consultancy&u=15", - rating: 100, - opinion: "Excellent knowledge about OSM", - metadata: { - nickname: "Pietervdvn", - } - }) - }) - } +window.setTimeout( + () => { +mangroveReviews.AddReview({ + comment: "These are liars - not even an island here!", + author: "Lost Tourist", + date: new Date(), + affiliated: false, + rating: 10 +}, (() => {alert("Review added");return undefined;})); + + }, 1000 +) + +window.setTimeout( + () => { + mangroveReviews.AddReview({ + comment: "Excellent conditions to measure weather!!", + author: "Weather-Boy", + date: new Date(), + affiliated: true, + rating: 90 + }, (() => { + alert("Review added"); + return undefined; + })); + + }, 1000 ) */ - -/* -// Given by a particular user since certain time. -const userReviews = await getReviews({ - kid: '-----BEGIN PUBLIC KEY-----MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDo6mN4kY6YFhpvF0u3hfVWD1RnDElPweX3U3KiUAx0dVeFLPAmeKdQY3J5agY3VspnHo1p/wH9hbZ63qPbCr6g==-----END PUBLIC KEY-----', - gt_iat: 1580860800 -})*/ - - /*/