diff --git a/.gitignore b/.gitignore index b24fc61..02b0461 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ .env .idea/ +.eslintrc +.vscode/ diff --git a/app/common/enums.js b/app/common/enums.js index 4737346..33cb41e 100644 --- a/app/common/enums.js +++ b/app/common/enums.js @@ -104,6 +104,13 @@ const AD_CATEGORY = { id: "FLAT", title: "Stan", hasGardenSize: false, + hasAccesRoadType: true, + hasBalconyProp: true, + hasNewBuildingProp: true, + hasElevatorProp: true, + hasNumberOfRoom: true, + hasNumberOfFloors: false, + hasFloorProp: true, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS @@ -112,6 +119,13 @@ const AD_CATEGORY = { id: "HOUSE", title: "Kuća", hasGardenSize: true, + hasAccesRoadType: true, + hasBalconyProp: true, + hasNewBuildingProp: true, + hasElevatorProp: false, + hasNumberOfRoom: true, + hasNumberOfFloors: true, + hasFloorProp: false, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, @@ -121,6 +135,13 @@ const AD_CATEGORY = { id: "OFFICE", title: "Kancelarija", hasGardenSize: false, + hasAccesRoadType: true, + hasBalconyProp: false, + hasNewBuildingProp: true, + hasElevatorProp: true, + hasNumberOfRoom: true, + hasNumberOfFloors: false, + hasFloorProp: true, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS @@ -129,6 +150,13 @@ const AD_CATEGORY = { id: "LAND", title: "Zemljište", hasGardenSize: false, + hasAccesRoadType: true, + hasBalconyProp: false, + hasNewBuildingProp: false, + hasElevatorProp: false, + hasNumberOfRoom: false, + hasNumberOfFloors: false, + hasFloorProp: false, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS @@ -137,6 +165,13 @@ const AD_CATEGORY = { id: "APARTMENT", title: "Apartman", hasGardenSize: false, + hasAccesRoadType: true, + hasBalconyProp: true, + hasNewBuildingProp: true, + hasElevatorProp: true, + hasNumberOfRoom: true, + hasNumberOfFloors: false, + hasFloorProp: true, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS @@ -145,6 +180,13 @@ const AD_CATEGORY = { id: "GARAGE", title: "Garaža", hasGardenSize: false, + hasAccesRoadType: true, + hasBalconyProp: false, + hasNewBuildingProp: false, + hasElevatorProp: false, + hasNumberOfRoom: false, + hasNumberOfFloors: false, + hasFloorProp: false, priceSliderOptionsSale: GARAGE_PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: GARAGE_PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS @@ -153,6 +195,13 @@ const AD_CATEGORY = { id: "COTTAGE", title: "Vikendica", hasGardenSize: true, + hasAccesRoadType: true, + hasBalconyProp: true, + hasNewBuildingProp: true, + hasElevatorProp: false, + hasNumberOfRoom: true, + hasNumberOfFloors: true, + hasFloorProp: false, priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, diff --git a/app/controllers/location.js b/app/controllers/location.js index afbf43f..214fcbf 100644 --- a/app/controllers/location.js +++ b/app/controllers/location.js @@ -4,9 +4,37 @@ const getLocation = async (req, res) => { const title = "Odaberite lokaciju"; const nextStep = req.query.nextStep || "/"; + //Check if location data already exists (active request) + //If it does then get location is called through edit field query + //and map should show already selected location not initial map + let selectedLatLngBounds = {}; + let boundsSelected = false; + + const searchRequest = await currentSearchRequest(req); + + if (!searchRequest || !searchRequest.dataValues) { + res.render("notFound", { title: " " }); + return; + } + const selectedArea = searchRequest.areaToSearch; + const sw = selectedArea.coordinates[0][3]; + const ne = selectedArea.coordinates[0][1]; + + if (sw[0] && ne[0]) { + selectedLatLngBounds = { + swLat: sw[1], + swLng: sw[0], + neLat: ne[1], + neLng: ne[0] + }; + boundsSelected = true; + } + res.render("location", { nextStep, - title + title, + boundsSelected, + selectedLatLngBounds }); }; diff --git a/app/controllers/realEstateFilters.js b/app/controllers/realEstateFilters.js index 7d6c06e..17f5e58 100644 --- a/app/controllers/realEstateFilters.js +++ b/app/controllers/realEstateFilters.js @@ -232,7 +232,6 @@ const postFilters = async (req, res) => { searchRequest.gardenSizeMin = gardenSizeMin; searchRequest.gardenSizeMax = gardenSizeMax; } - await searchRequest.save(); res.redirect(nextStepUrl); diff --git a/app/crawler/specificCrawlers/rental.js b/app/crawler/specificCrawlers/rental.js index 8f38dc8..39eb1c5 100644 --- a/app/crawler/specificCrawlers/rental.js +++ b/app/crawler/specificCrawlers/rental.js @@ -312,7 +312,7 @@ class RentalCrawler { let numberOfRooms = parseInt(extractedData["re_realEstates_roomsNO"]) + - parseInt(extractedData["re_realEstates_bedroomNO"]) || null, + parseInt(extractedData["re_realEstates_bedNO"]) || null, numberOfFloors = parseInt(extractedData["re_realEstates_floorsNO"]) || this.getNumberOfFloorsFromFloorId(extractedData["re_floorNO_id"]), @@ -352,7 +352,9 @@ class RentalCrawler { realEstatePropertiesFromInfrastructure.phoneConnection, cableTV = realEstatePropertiesFromInfrastructure.cableTV, internet = realEstatePropertiesFromInfrastructure.internet, - basementAttic = realEstatePropertiesFromSpaces.basementAttic, + basementAttic = + realEstatePropertiesFromSpaces.basementAttic || + this.checkBasemAtticFromFloors(extractedData["re_floorNO_id"]), storeRoom = realEstatePropertiesFromSpaces.storeRoom, videoSurveillance = realEstatePropertiesFromDescriptions.videoSurveillance || @@ -397,9 +399,7 @@ class RentalCrawler { ); if (!publishedDateMoment.isValid()) { throw { - message: `Invalid published date : ${ - extractedData["re_realEstates_inserted"] - }` + message: `Invalid published date : ${extractedData["re_realEstates_inserted"]}` }; } @@ -410,9 +410,7 @@ class RentalCrawler { ); if (!renewedDateMoment.isValid()) { throw { - message: `Invalid renewed date : ${ - extractedData["re_realEstates_edited"] - }` + message: `Invalid renewed date : ${extractedData["re_realEstates_edited"]}` }; } @@ -782,8 +780,42 @@ class RentalCrawler { if (floorIds.length === 0) { return null; } + let noOfFloors = floorIds.length; + // Floors of 'suteren', 'podrum', 'tavan' and 'potkrovlje' are not counted + floorIds.forEach(id => { + if ( + parseInt(id) === 1 || + parseInt(id) === 2 || + parseInt(id) === 12 || + parseInt(id) === 14 + ) { + noOfFloors--; + } + }); + return noOfFloors; + } - return floorIds.length; + checkBasemAtticFromFloors(floorsIdText) { + // floorIdText can be array of numbers, separated by comma or number + const floorIds = floorsIdText.split(","); + + let check = false; + + if (floorIds.length === 0) { + check = false; + } + //If floors 'suteren', 'podrum', 'tavan' and 'potkrovlje' exists then tag for basement-attic is true + floorIds.forEach(id => { + if ( + parseInt(id) === 1 || + parseInt(id) === 2 || + parseInt(id) === 12 || + parseInt(id) === 14 + ) { + check = true; + } + }); + return check; } async sleep(ms) { diff --git a/app/helpers/db/realEstate.js b/app/helpers/db/realEstate.js index 0282645..ebeb84c 100644 --- a/app/helpers/db/realEstate.js +++ b/app/helpers/db/realEstate.js @@ -2,7 +2,6 @@ const db = require("../../models/index"); const sequelize = require("sequelize"); const Op = sequelize.Op; - const bulkUpsertRealEstates = async realEstateData => { try { const fieldsToUpdateIfDuplicate = [ @@ -87,7 +86,20 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => { sizeMax, adType, realEstateType, - areaToSearch + areaToSearch, + gardenSizeMin, + gardenSizeMax, + numberOfRoomsMin, + numberOfRoomsMax, + numberOfFloorsMin, + numberOfFloorsMax, + floorMin, + floorMax, + includeIncompleteAds, + balcony, + elevator, + newBuilding, + accessRoadType } = searchRequest; const longitudeColumn = sequelize.col("locationLong"); @@ -116,12 +128,20 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => { const geoSearchQueryPart = sequelize.where(contains, true); + //General queries contain only attributes that are defined for every searchreq + + //Query for case of complete ads const query = { adType, realEstateType, price: { - [Op.lte]: priceMax, - [Op.gte]: priceMin + [Op.or]: { + [Op.and]: { + [Op.lte]: priceMax, + [Op.gte]: priceMin + }, + [Op.is]: null + } }, area: { [Op.lte]: sizeMax, @@ -130,10 +150,148 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => { [Op.and]: geoSearchQueryPart }; + //Query for case of incomplete ads + const queryIncludeIncomplete = { + adType, + realEstateType, + price: { + [Op.or]: { + [Op.and]: { + [Op.lte]: priceMax, + [Op.gte]: priceMin + }, + [Op.is]: null + } + }, + area: { + [Op.or]: { + [Op.and]: { + [Op.lte]: sizeMax, + [Op.gte]: sizeMin + }, + [Op.is]: null + } + }, + [Op.and]: geoSearchQueryPart + }; + + //Every other attribute is checked separately and included in query only if it is defined + if (gardenSizeMax && gardenSizeMin) { + query.gardenSize = { + [Op.lte]: gardenSizeMax, + [Op.gte]: gardenSizeMin + }; + queryIncludeIncomplete.gardenSize = { + [Op.or]: { + [Op.and]: { + [Op.lte]: gardenSizeMax, + [Op.gte]: gardenSizeMin + }, + [Op.is]: null + } + }; + } + + if (numberOfRoomsMin && numberOfRoomsMax) { + query.numberOfRooms = { + [Op.lte]: numberOfRoomsMax, + [Op.gte]: numberOfRoomsMin + }; + queryIncludeIncomplete.numberOfRooms = { + [Op.or]: { + [Op.and]: { + [Op.lte]: numberOfRoomsMax, + [Op.gte]: numberOfRoomsMin + }, + [Op.is]: null + } + }; + } + + if (numberOfFloorsMin && numberOfFloorsMax) { + query.numberOfFloors = { + [Op.lte]: numberOfFloorsMax, + [Op.gte]: numberOfFloorsMin + }; + queryIncludeIncomplete.numberOfFloors = { + [Op.or]: { + [Op.and]: { + [Op.lte]: numberOfFloorsMax, + [Op.gte]: numberOfFloorsMin + }, + [Op.is]: null + } + }; + } + + if (floorMin && floorMax) { + query.floor = { + [Op.lte]: floorMax, + [Op.gte]: floorMin + }; + queryIncludeIncomplete.floor = { + [Op.or]: { + [Op.and]: { + [Op.lte]: floorMax, + [Op.gte]: floorMin + }, + [Op.is]: null + } + }; + } + + if (balcony) { + query.balcony = { + [Op.eq]: balcony + }; + queryIncludeIncomplete.balcony = { + [Op.or]: { + [Op.eq]: balcony, + [Op.is]: null + } + }; + } + + if (newBuilding) { + query.newBuilding = { + [Op.eq]: newBuilding + }; + queryIncludeIncomplete.newBuilding = { + [Op.or]: { + [Op.eq]: newBuilding, + [Op.is]: null + } + }; + } + + if (elevator) { + query.elevator = { + [Op.eq]: elevator + }; + queryIncludeIncomplete.elevator = { + [Op.or]: { + [Op.eq]: elevator, + [Op.is]: null + } + }; + } + + if (accessRoadType !== "ANY") { + query.accessRoadType = { + [Op.eq]: accessRoadType + }; + queryIncludeIncomplete.accessRoadType = { + [Op.or]: { + [Op.eq]: accessRoadType, + [Op.is]: null + } + }; + } + const order = [["updatedAt", "desc"]]; - return await db.RealEstate.findAll({ - where: query, + return db.RealEstate.findAll({ + where: includeIncompleteAds ? queryIncludeIncomplete : query, limit: maxResults, order }); diff --git a/app/helpers/db/searchRequest.js b/app/helpers/db/searchRequest.js index 6e47b5d..808637a 100644 --- a/app/helpers/db/searchRequest.js +++ b/app/helpers/db/searchRequest.js @@ -2,11 +2,13 @@ const db = require("../../models/index"); const sequelize = require("sequelize"); const Op = sequelize.Op; +const { AD_CATEGORY } = require("../../common/enums"); const getSearchRequest = async searchRequestId => { try { return await db.SearchRequest.findByPk(searchRequestId); } catch (error) { + console.log("searchrequest.js", error); return null; } }; @@ -22,7 +24,15 @@ const findSearchRequestsForRealEstate = async realEstate => { adType, realEstateType, locationLat, - locationLong + locationLong, + accessRoadType, + balcony, + newBuilding, + elevator, + gardenSize, + numberOfRooms, + numberOfFloors, + floor } = realEstate; if (!locationLat || !locationLong) { @@ -39,12 +49,20 @@ const findSearchRequestsForRealEstate = async realEstate => { const geoSearchQueryPart = sequelize.where(contains, true); + //General query contains only attributes that are defined for every RealEstate - not null const query = { adType, realEstateType, subscribed: true, [Op.and]: geoSearchQueryPart }; + //Needed for defining which attribute should exist or not + const realEstateTypeObject = AD_CATEGORY[realEstateType]; + //Needed to decide on including incomplete RealEstates data + let checkForIncompleteWanted = false; + + //Attributes are checked separately and included in query only if defined + //Price and area should be defined for every property if (price) { query.priceMin = { @@ -62,8 +80,96 @@ const findSearchRequestsForRealEstate = async realEstate => { query.sizeMax = { [Op.gte]: area }; + } else { + checkForIncompleteWanted = true; + } + //Other attributes can be defined or not depending on RealEstate type + if (gardenSize) { + query.gardenSizeMin = { + [Op.lte]: gardenSize + }; + query.gardenSizeMax = { + [Op.gte]: gardenSize + }; + } else if (realEstateTypeObject.hasGardenSize) { + checkForIncompleteWanted = true; } + if (numberOfRooms) { + query.numberOfRoomsMin = { + [Op.lte]: numberOfRooms + }; + query.numberOfRoomsMax = { + [Op.gte]: numberOfRooms + }; + } else if (realEstateTypeObject.hasNumberOfRoom) { + checkForIncompleteWanted = true; + } + + if (numberOfFloors) { + query.numberOfFloorsMin = { + [Op.lte]: numberOfFloors + }; + query.numberOfFloorsMax = { + [Op.gte]: numberOfFloors + }; + } else if (realEstateTypeObject.hasNumberOfFloors) { + checkForIncompleteWanted = true; + } + + if (floor) { + query.floorMin = { + [Op.lte]: floor + }; + query.floorMax = { + [Op.gte]: floor + }; + } else if (realEstateTypeObject.hasFloorProp) { + checkForIncompleteWanted = true; + } + + if (accessRoadType) { + query.accessRoadType = { + [Op.or]: { + [Op.eq]: "ANY", + [Op.eq]: accessRoadType + } + }; + } else if (realEstateTypeObject.hasAccesRoadType) { + checkForIncompleteWanted = true; + } + + if (balcony) { + query.balcony = { + [Op.eq]: balcony + }; + } else if (realEstateTypeObject.hasBalconyProp) { + checkForIncompleteWanted = true; + } + + if (newBuilding) { + query.newBuilding = { + [Op.eq]: newBuilding + }; + } else if (realEstateTypeObject.hasNewBuildingProp) { + checkForIncompleteWanted = true; + } + + if (elevator) { + query.elevator = { + [Op.eq]: elevator + }; + } else if (realEstateTypeObject.hasElevatorProp) { + checkForIncompleteWanted = true; + } + + //If one of the attributes that exists for property type is null + //we include in query to check if incomplete real estates are accepted + if (checkForIncompleteWanted) { + query.includeIncompleteAds = { + [Op.eq]: true + }; + } return await db.SearchRequest.findAll({ where: query }); }; diff --git a/app/views/location.ejs b/app/views/location.ejs index 01a25ef..8e30feb 100644 --- a/app/views/location.ejs +++ b/app/views/location.ejs @@ -1,13 +1,17 @@

- Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice - koje želite da budu vidljive. + Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice koje + želite da budu vidljive.

- +
@@ -17,12 +21,17 @@ -
+
- Dalje + Dalje
@@ -41,15 +50,15 @@ function locateMe() { if (navigator.geolocation) { - - function onLocationSuccess (position) { - const coordinates = position && position.coords ? position.coords : null; - if (coordinates){ + function onLocationSuccess(position) { + const coordinates = + position && position.coords ? position.coords : null; + if (coordinates) { const longitude = coordinates.longitude || null; const latitude = coordinates.latitude || null; - if (longitude && latitude && map){ - map.setCenter({lat: latitude, lng: longitude}); + if (longitude && latitude && map) { + map.setCenter({ lat: latitude, lng: longitude }); map.setZoom(16); } } @@ -61,20 +70,20 @@ function initMap() { const BOSNIA_BOUNDS = { - north: 45.70, + north: 45.7, south: 41.69, west: 15.55, - east: 20.77, + east: 20.77 }; const SARAJEVO_COORDINATES = { lat: 43.85, - lng: 18.41, + lng: 18.41 }; - const mapElement = document.getElementById('map'); + const mapElement = document.getElementById("map"); const restrictMapPanningToBosniaOnly = { latLngBounds: BOSNIA_BOUNDS, - strictBounds: true, + strictBounds: true }; const initialMapParams = { center: SARAJEVO_COORDINATES, @@ -87,38 +96,50 @@ }; map = new google.maps.Map(mapElement, initialMapParams); - const inputElement = document.getElementById('autocompleteInput'); - const restrictAutocompleteResultsToBosniaOnly = {'country': 'ba'}; + const inputElement = document.getElementById("autocompleteInput"); + const restrictAutocompleteResultsToBosniaOnly = { country: "ba" }; const initialAutocompleteParams = { - types: ['geocode'], + types: ["geocode"], componentRestrictions: restrictAutocompleteResultsToBosniaOnly, - fields: ['geometry', 'types', 'address_components'] + fields: ["geometry", "types", "address_components"] }; - autocomplete = new google.maps.places.Autocomplete(inputElement, initialAutocompleteParams); - autocomplete.bindTo('bounds', map); - autocomplete.addListener('place_changed', onPlaceChanged); + autocomplete = new google.maps.places.Autocomplete( + inputElement, + initialAutocompleteParams + ); + autocomplete.bindTo("bounds", map); + autocomplete.addListener("place_changed", onPlaceChanged); pacSelectFirst(inputElement); addLocateMeButton(map); + + //After map initialization we check if area is already selected + //If yes we bound map to show already selected area + const boundsSelected = <%- boundsSelected %>; + const selectedLatLngBounds = <%- JSON.stringify(selectedLatLngBounds) %>; + + if (boundsSelected) { + boundMapToSelected(map, selectedLatLngBounds); + } } function addLocateMeButton(map) { - var parent = document.createElement('div'); + var parent = document.createElement("div"); parent.className = "locate-me-container"; - var a = document.createElement('a'); + var a = document.createElement("a"); a.id = "locateMe"; a.className = "btn-floating"; - var i = document.createElement('i'); + var i = document.createElement("i"); i.innerText = "gps_fixed"; i.className = "material-icons right"; - a.appendChild(i) + a.appendChild(i); a.addEventListener("click", locateMe); - parent.appendChild(a) + parent.appendChild(a); - map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent) + map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent); } function onPlaceChanged() { @@ -133,33 +154,49 @@ function pacSelectFirst(input) { // store the original event binding function const _addEventListener = input.addEventListener - ? input.addEventListener - : input.attachEvent + ? input.addEventListener + : input.attachEvent; - function addEventListenerWrapper (type, listener) { + function addEventListenerWrapper(type, listener) { // Simulate a 'down arrow' keypress on hitting 'return' when no pac suggestion is selected, // and then trigger the original listener. - if (type == 'keydown') { - const originalListener = listener - listener = function (event) { - const suggestionSelected = $('.pac-item-selected').length > 0 - if (event.key == 'Enter' && !suggestionSelected) { - const simulatedDownArrow = $.Event('keydown', { + if (type == "keydown") { + const originalListener = listener; + listener = function(event) { + const suggestionSelected = $(".pac-item-selected").length > 0; + if (event.key == "Enter" && !suggestionSelected) { + const simulatedDownArrow = $.Event("keydown", { keyCode: 40, which: 40 - }) - originalListener.apply(input, [simulatedDownArrow]) + }); + originalListener.apply(input, [simulatedDownArrow]); } - originalListener.apply(input, [event]) - } + originalListener.apply(input, [event]); + }; } - _addEventListener.apply(input, [type, listener]) + _addEventListener.apply(input, [type, listener]); } - input.addEventListener = addEventListenerWrapper - input.attachEvent = addEventListenerWrapper + input.addEventListener = addEventListenerWrapper; + input.attachEvent = addEventListenerWrapper; + } + function boundMapToSelected(map, selectedLatLngBounds) { + + const swBound = new google.maps.LatLng( + selectedLatLngBounds.swLat, + selectedLatLngBounds.swLng + ); + const neBound = new google.maps.LatLng( + selectedLatLngBounds.neLat, + selectedLatLngBounds.neLng + ); + let bounds = new google.maps.LatLngBounds(); + bounds.extend(swBound); + bounds.extend(neBound); + + map.fitBounds(bounds); } $(document).ready(function() { @@ -171,11 +208,16 @@ $("#east").val(mapBounds.getNorthEast().lng()); $("#west").val(mapBounds.getSouthWest().lng()); - $("#locationInput").val(document.getElementById('autocompleteInput').value); + $("#locationInput").val( + document.getElementById("autocompleteInput").value + ); $("#form-map-output").submit(); }); }); - + diff --git a/app/views/standardFilters.ejs b/app/views/standardFilters.ejs index 423d694..27a6bba 100644 --- a/app/views/standardFilters.ejs +++ b/app/views/standardFilters.ejs @@ -1,66 +1,73 @@ -
+
-
Cijena
-

+
Cijena (KM)
+

-
+
- - +
- +
-
+
-
Površina
-

+
Površina (m2)
+

-
+
- - +
- +
-
+
<% if(hasGardenSize) { %> -
-
Površina okućnice
-

-
-
-
+
+
Površina okućnice (m2)
+

+
+
+
-
-
-
- - -
-
- -
+
+
+
+
+
+ +
+
<% } %>