/** Our Little Home registry. Deploy as a web app: execute as owner, access Anyone. * The spreadsheet stays PRIVATE. Every request requires a server-only service key. * Run setupRegistry once. Copy REGISTRY_SERVICE_KEY from Project Settings > Script Properties * into Cloudflare's secret REGISTRY_SERVICE_KEY. Never put it in public files. */ var GIFT_HEADERS=['id','title','category','model','description','targetCents','quantity','priority','storeUrl','image','visible','purchased','note','order']; var CONTRIBUTION_HEADERS=['id','giftId','giftTitle','targetSnapshotCents','name','email','message','amountCents','method','state','createdAt','updatedAt','tokenHash','amountUsdCents','eurToUsdSnapshot','amountUSD','amountEUR','rateDateSnapshot','currencyRecord']; function setupRegistry(){ var p=PropertiesService.getScriptProperties(); if(p.getProperty('REGISTRY_SHEET_ID')){console.log('Already set up: https://docs.google.com/spreadsheets/d/'+p.getProperty('REGISTRY_SHEET_ID')+'/edit');return;} var ss=SpreadsheetApp.getActiveSpreadsheet(); if(!ss)throw new Error('Open a NEW empty Google Sheet and paste this script through Extensions > Apps Script.'); if(ss.getSheets().length!==1||ss.getSheets()[0].getDataRange().getValues().some(function(r){return r.some(function(v){return v!=='';});}))throw new Error('Please use a new, empty spreadsheet. Existing data will not be overwritten.'); var gifts=ss.getSheets()[0];gifts.setName('Gifts');gifts.appendRow(GIFT_HEADERS); SEED_GIFTS.forEach(function(g){gifts.appendRow(GIFT_HEADERS.map(function(k){return g[k]===null?'':g[k];}));}); var c=ss.insertSheet('Contributions');c.appendRow(CONTRIBUTION_HEADERS); var settings=ss.insertSheet('Settings');settings.appendRow(['key','value','help']); var rows=[['title','Our Little Home','Public registry title'],['coupleNames','','Names shown under the introduction'],['intro','A few things to help us build a home, one lovely everyday moment at a time.','Public introduction'],['venmoUrl','','Exact HTTPS Venmo profile/payment link you provide'],['venmoQrUrl','','HTTPS image URL or assets/venmo-qr.png; provide your real Venmo QR image'],['eurToUsd','','Planning EUR to USD rate; required before enabling Venmo'],['rateDate','','Date/source of the displayed planning rate'],['cashInstructions','Please give your cash gift to us in person. We will mark it as received once it reaches us.','Cash instructions'],['contactText','Please get in touch with us if you have a question about a gift.','Public contact text; do not add information you want to keep private'],['turnstileSiteKey','','PUBLIC Cloudflare Turnstile site key. Not the secret key.']]; settings.getRange(2,1,rows.length,3).setValues(rows); var history=ss.insertSheet('History');history.appendRow(['timestamp','action','reference','details']); [gifts,c,settings,history].forEach(function(sh){sh.setFrozenRows(1);sh.getRange(1,1,1,sh.getLastColumn()).setFontWeight('bold').setBackground('#e6e9dc');sh.autoResizeColumns(1,sh.getLastColumn());}); gifts.getRange(2,8,Math.max(SEED_GIFTS.length,100),1).insertCheckboxes(); gifts.getRange(2,11,Math.max(SEED_GIFTS.length,100),2).insertCheckboxes(); // insertCheckboxes sets defaults. Restore original priority / visibility / purchase values. SEED_GIFTS.forEach(function(g,i){gifts.getRange(i+2,8).setValue(g.priority===true);gifts.getRange(i+2,11,1,2).setValues([[g.visible!==false,g.purchased===true]]);}); var validation=SpreadsheetApp.newDataValidation().requireValueInList(['pending','received','cancelled'],true).setAllowInvalid(false).build(); c.getRange(2,10,c.getMaxRows()-1,1).setDataValidation(validation); p.setProperty('REGISTRY_SHEET_ID',ss.getId()); if(!p.getProperty('REGISTRY_SERVICE_KEY'))p.setProperty('REGISTRY_SERVICE_KEY',Utilities.getUuid().replace(/-/g,'')+Utilities.getUuid().replace(/-/g,'')); formatCurrencySheet_(c); console.log('Your private management spreadsheet: '+ss.getUrl()); console.log('Next: Project Settings > Script Properties. Copy REGISTRY_SERVICE_KEY directly into Cloudflare. Do not share it in chat.'); } function onOpen(){registryMenu();} function registryMenu(){SpreadsheetApp.getUi().createMenu('Registry').addItem('Confirm selected payments received','confirmSelectedPayments').addItem('Cancel selected unpaid reservations','cancelSelectedPending').addItem('Upgrade currency records (v2)','upgradeCurrencyRecords').addToUi();} function ss_(){var id=PropertiesService.getScriptProperties().getProperty('REGISTRY_SHEET_ID');if(!id)throw new Error('SETUP_REQUIRED');return SpreadsheetApp.openById(id);} function objects_(sheet){var values=sheet.getDataRange().getValues();var headers=values.shift();return values.map(function(row,i){var obj={_row:i+2};headers.forEach(function(h,j){obj[h]=row[j];});return obj;}).filter(function(o){return o[headers[0]]!=='';});} function settings_(ss){var r={};objects_(ss.getSheetByName('Settings')).forEach(function(x){r[x.key]=x.value;});return r;} function bool_(value,fallback){if(value==='')return fallback;return value===true||String(value).toLowerCase()==='true';} function gifts_(ss){return objects_(ss.getSheetByName('Gifts')).map(function(g){g.targetCents=g.targetCents===''?null:Number(g.targetCents);g.quantity=Number(g.quantity)||1;g.order=Number(g.order)||999;g.priority=bool_(g.priority,false);g.visible=bool_(g.visible,true);g.purchased=bool_(g.purchased,false);return g;});} function hash_(s){return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,s,Utilities.Charset.UTF_8).map(function(b){return ('0'+((b+256)%256).toString(16)).slice(-2);}).join('');} function json_(data){return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON);} function validAmount_(n){return typeof n==='number'&&isFinite(n)&&Math.floor(n)===n&&n>0&&n<=10000000;} function https_(s){return typeof s==='string'&&/^https:\/\//i.test(s);} function text_(s,max){return String(s||'').trim().slice(0,max);} function sheetText_(s){s=String(s||'');return /^[=+\-@\t\r]/.test(s)?"'"+s:s;} function publicConfig_(s){var info=effectiveRate_(s),r={mode:'live',currencyVersion:2,eurToUsd:info.rate,rateDate:info.date,venmoEnabled:https_(s.venmoUrl)&&validRate_(Number(s.eurToUsd))};['title','coupleNames','intro','venmoUrl','venmoQrUrl','cashInstructions','contactText','turnstileSiteKey'].forEach(function(k){r[k]=String(s[k]||'');});return r;} function totals_(rows,id,exceptId){var received=0,pending=0;rows.forEach(function(c){if(c.giftId!==id||c.id===exceptId)return;if(c.state==='received')received+=Number(c.amountCents)||0;if(c.state==='pending')pending+=Number(c.amountCents)||0;});return {received:received,pending:pending};} function publicGift_(g,rows){var t=totals_(rows,g.id);var o={};GIFT_HEADERS.forEach(function(k){o[k]=g[k];});o.receivedCents=t.received;o.pledgedCents=t.pending;return o;} function publicContribution_(c){var o={};['id','giftId','giftTitle','name','email','message','amountCents','method','state','createdAt','updatedAt','amountUsdCents','eurToUsdSnapshot','rateDateSnapshot','currencyRecord'].forEach(function(k){o[k]=c[k];});return o;} function audit_(ss,action,id,details){ss.getSheetByName('History').appendRow([new Date().toISOString(),action,id,sheetText_(String(details||''))]);} function mutate_(ss,c,changes){var sheet=ss.getSheetByName('Contributions');currencySchema_(sheet);Object.keys(changes).forEach(function(k){var idx=CONTRIBUTION_HEADERS.indexOf(k);if(idx>=0)sheet.getRange(c._row,idx+1).setValue(typeof changes[k]==='string'?sheetText_(changes[k]):changes[k]);c[k]=changes[k];});return c;} // One source of truth for new requests. Settings can override this dated estimate. var CURRENCY_FALLBACK_RATE=1.1253; var CURRENCY_FALLBACK_DATE='16 Oct 2026 (ECB display estimate)'; function validRate_(n){return typeof n==='number'&&isFinite(n)&&n>0;} function effectiveRate_(s){var rate=Number(s.eurToUsd);return validRate_(rate)?{rate:rate,date:text_(s.rateDate,150)}:{rate:CURRENCY_FALLBACK_RATE,date:CURRENCY_FALLBACK_DATE};} function hasUsd_(c){return validAmount_(Number(c.amountUsdCents));} function currencySchema_(sheet){var headers=sheet.getDataRange().getValues()[0]||[];if(CONTRIBUTION_HEADERS.some(function(k,i){return headers[i]!==k;}))throw new Error('UPGRADE_REQUIRED');} function currencyFields_(req,settings,previous,maxEur){ var info=previous&&validRate_(Number(previous.eurToUsdSnapshot))?{rate:Number(previous.eurToUsdSnapshot),date:String(previous.rateDateSnapshot||'Not recorded (legacy rate)')}:effectiveRate_(settings); if(req.amountUsdCents===undefined&&req.eurToUsdSnapshot===undefined){ // Older clients cannot prove which USD amount was entered, especially for Cash. var known=previous&&validRate_(Number(previous.eurToUsdSnapshot)); if(known||req.method==='venmo'){ var usd=previous&&Number(previous.amountCents)===req.amountCents&&hasUsd_(previous)?Number(previous.amountUsdCents):Math.round(req.amountCents*info.rate); return {amountUsdCents:usd,eurToUsdSnapshot:info.rate,rateDateSnapshot:info.date,currencyRecord:previous?'legacy_eur_edit':'legacy_stored',amountUSD:usd/100,amountEUR:req.amountCents/100}; } return {amountUsdCents:'',eurToUsdSnapshot:'',rateDateSnapshot:'',currencyRecord:'legacy_unknown',amountUSD:'',amountEUR:req.amountCents/100}; } if(!validAmount_(req.amountUsdCents)||!validRate_(req.eurToUsdSnapshot))throw new Error('INVALID_CURRENCY'); if(req.eurToUsdSnapshot!==info.rate)throw new Error('RATE_CHANGED'); var maxUsd=Math.round(maxEur*info.rate); if(req.amountUsdCents>maxUsd)throw new Error('AMOUNT_UNAVAILABLE'); var expected=req.amountUsdCents===maxUsd?maxEur:Math.round(req.amountUsdCents/info.rate); // Saving an unchanged exact USD amount must not move an accounting cent. if(previous&&validRate_(Number(previous.eurToUsdSnapshot))&&req.amountUsdCents===Number(previous.amountUsdCents))expected=Number(previous.amountCents); if(!validAmount_(expected)||req.amountCents!==expected)throw new Error('INVALID_CURRENCY'); return {amountUsdCents:req.amountUsdCents,eurToUsdSnapshot:info.rate,rateDateSnapshot:info.date,currencyRecord:'captured_usd',amountUSD:req.amountUsdCents/100,amountEUR:req.amountCents/100}; } function currencyAudit_(c){return JSON.stringify({amountUsdCents:c.amountUsdCents||null,amountEurCents:c.amountCents,eurToUsd:c.eurToUsdSnapshot||null,rateDate:c.rateDateSnapshot||'',record:c.currencyRecord||'legacy_unknown'});} function formatCurrencySheet_(sheet){ var count=Math.max(1,sheet.getMaxRows()-1); sheet.getRange(2,16,count,1).setNumberFormat('"$"#,##0.00'); sheet.getRange(2,17,count,1).setNumberFormat('"€"#,##0.00'); sheet.getRange(2,15,count,1).setNumberFormat('0.000000'); sheet.getRange(1,16,1,4).setFontWeight('bold').setBackground('#efe1e6'); sheet.getRange(1,8).setNote('EUR CENTS used for accounting: 2222 = EUR 22.22. For readable amounts use amountUSD and amountEUR.'); sheet.getRange(1,14).setNote('USD CENTS: 2500 = USD 25.00. Blank on old Cash records means the original USD amount was not recorded.'); sheet.getRange(1,16).setNote('Current reserved USD amount, captured before conversion. Changes requested by the guest are recorded in History.'); sheet.getRange(1,17).setNote('Reserved EUR equivalent at the saved planning rate, not an automatically verified cash or bank receipt.'); sheet.getRange(1,18).setNote('Date/source saved with the reservation. A blank or legacy label means the historical date is unknown.'); sheet.getRange(1,19).setNote('captured_usd: exact USD input saved. legacy_stored: existing USD retained. legacy_unknown: original USD unknown. legacy_eur_edit: old client edited EUR.'); sheet.autoResizeColumns(16,4); } function upgradeCurrencyRecords(){ var lock=LockService.getScriptLock();if(!lock.tryLock(10000))throw new Error('The registry is busy. Try again shortly.'); try{ var ss=ss_(),sheet=ss.getSheetByName('Contributions'),headers=sheet.getDataRange().getValues()[0]||[],base=CONTRIBUTION_HEADERS.slice(0,15); if(base.some(function(k,i){return headers[i]!==k;}))throw new Error('Unexpected Contributions headings. No data was changed.'); var complete=CONTRIBUTION_HEADERS.every(function(k,i){return headers[i]===k;}); if(!complete){ if(headers.slice(15).some(function(h){return String(h||'').trim()!=='';}))throw new Error('Extra columns were found after the original 15 columns. No data was changed.'); // Backup first; never reset Gifts, Settings, tokens, states or the service key. var backup='Contributions_backup_'+new Date().toISOString().replace(/\D/g,'')+'_'+Utilities.getUuid().slice(0,6); sheet.copyTo(ss).setName(backup); if(sheet.getMaxColumns()12000)throw new Error('INVALID_REQUEST'); var req=JSON.parse(event.postData.contents);var expected=PropertiesService.getScriptProperties().getProperty('REGISTRY_SERVICE_KEY'); if(!expected||typeof req.serviceKey!=='string'||hash_(req.serviceKey)!==hash_(expected))return json_({ok:false,error:'ACCESS_DENIED'}); var action=req.action; if(['catalogue','reserve','manage','change','cancel'].indexOf(action)<0)throw new Error('INVALID_REQUEST'); if(['reserve','change','cancel'].indexOf(action)>=0){lock=LockService.getScriptLock();if(!lock.tryLock(10000))throw new Error('BUSY');} var ss=ss_(), settings=settings_(ss), gifts=gifts_(ss), sheet=ss.getSheetByName('Contributions'), rows=objects_(sheet); if(action==='catalogue')return json_({ok:true,config:publicConfig_(settings),gifts:gifts.filter(function(g){return g.visible;}).map(function(g){return publicGift_(g,rows);})}); if(action==='reserve'){ if(typeof req.reservationToken!=='string'||!/^[a-zA-Z0-9-]{60,100}$/.test(req.reservationToken))throw new Error('INVALID_REQUEST'); var reservationHash=hash_(req.reservationToken); var previous=rows.filter(function(x){return x.tokenHash===reservationHash;})[0]; if(previous){return json_({ok:true,contribution:publicContribution_(previous),manageToken:req.reservationToken});} var g=gifts.filter(function(x){return x.id===req.giftId;})[0]; if(!g||!g.visible||g.purchased||!validAmount_(g.targetCents))throw new Error('GIFT_UNAVAILABLE'); if(!validAmount_(req.amountCents))throw new Error('INVALID_AMOUNT'); var t=totals_(rows,g.id),available=Math.max(0,g.targetCents-t.received-t.pending); if(req.amountCents>available)throw new Error('AMOUNT_UNAVAILABLE'); var name=text_(req.name,100),email=text_(req.email,150),message=text_(req.message,500),method=req.method; if(!name||(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)))throw new Error('INVALID_DETAILS'); if(['cash','venmo'].indexOf(method)<0)throw new Error('INVALID_METHOD'); if(method==='venmo'&&(!https_(settings.venmoUrl)||!validRate_(Number(settings.eurToUsd))))throw new Error('VENMO_NOT_READY'); var token=req.reservationToken,now=new Date().toISOString(); var c={id:'GIFT-'+Utilities.getUuid().slice(0,8).toUpperCase(),giftId:g.id,giftTitle:g.title,targetSnapshotCents:g.targetCents,name:name,email:email,message:message,amountCents:req.amountCents,method:method,state:'pending',createdAt:now,updatedAt:now,tokenHash:hash_(token)}; var fx=currencyFields_(req,settings,null,available);Object.keys(fx).forEach(function(k){c[k]=fx[k];});currencySchema_(sheet); sheet.appendRow(CONTRIBUTION_HEADERS.map(function(k){return typeof c[k]==='string'?sheetText_(c[k]):c[k];})); audit_(ss,'reserved',c.id,currencyAudit_(c)+' for '+g.id);SpreadsheetApp.flush(); return json_({ok:true,contribution:publicContribution_(c),manageToken:token}); } if(typeof req.manageToken!=='string'||!/^[a-zA-Z0-9-]{20,100}$/.test(req.manageToken))throw new Error('NOT_FOUND'); var hash=hash_(req.manageToken),c=rows.filter(function(x){return x.tokenHash===hash;})[0];if(!c)throw new Error('NOT_FOUND'); if(action==='manage')return json_({ok:true,contribution:publicContribution_(c)}); if(c.state!=='pending')throw new Error('ALREADY_PROCESSED'); if(action==='cancel'){ mutate_(ss,c,{state:'cancelled',updatedAt:new Date().toISOString()});audit_(ss,'cancelled',c.id,'Unpaid reservation cancelled by its holder');SpreadsheetApp.flush();return json_({ok:true,contribution:publicContribution_(c)}); } if(action==='change'){ if(!validAmount_(req.amountCents))throw new Error('INVALID_AMOUNT'); var g=gifts.filter(function(x){return x.id===c.giftId;})[0];if(!g||!g.visible||g.purchased||!validAmount_(g.targetCents))throw new Error('GIFT_UNAVAILABLE'); var t=totals_(rows,g.id,c.id);if(req.amountCents>g.targetCents-t.received-t.pending)throw new Error('AMOUNT_UNAVAILABLE'); var changes={amountCents:req.amountCents,updatedAt:new Date().toISOString()}; var previousCurrency=currencyAudit_(c),fx=currencyFields_(req,settings,c,Math.max(0,g.targetCents-t.received-t.pending));Object.keys(fx).forEach(function(k){changes[k]=fx[k];}); mutate_(ss,c,changes);audit_(ss,'amount_changed',c.id,'Previous '+previousCurrency+'; new '+currencyAudit_(c));SpreadsheetApp.flush();return json_({ok:true,contribution:publicContribution_(c)}); } }catch(err){return json_({ok:false,error:mapError_(err.message)});}finally{if(lock)lock.releaseLock();} } function mapError_(s){var errors={RATE_CHANGED:'The planning exchange rate changed. Close and reopen this gift to review the new rate before reserving.',INVALID_CURRENCY:'The currency amounts could not be verified. Please reopen the gift and try again.',UPGRADE_REQUIRED:'The registry is being updated. Please try again shortly.',INVALID_REQUEST:'Invalid request.',BUSY:'The registry is busy. Please try again in a moment.',GIFT_UNAVAILABLE:'This gift is not currently available for reservations.',INVALID_AMOUNT:'Please enter a valid amount.',AMOUNT_UNAVAILABLE:'That amount is no longer available. Please refresh the gift and choose a smaller amount.',INVALID_DETAILS:'Please check your name and email.',INVALID_METHOD:'Please choose cash or Venmo.',VENMO_NOT_READY:'Venmo details are not available yet. Please choose cash.',NOT_FOUND:'We could not find that private link.',ALREADY_PROCESSED:'This contribution has already been received or cancelled. Please contact the couple for changes.'};return errors[s]||'The registry is temporarily unavailable. Please contact the couple if the problem continues.';} function selectedAction_(kind){ var ss=SpreadsheetApp.getActiveSpreadsheet(),sh=ss.getActiveSheet();if(sh.getName()!=='Contributions'){SpreadsheetApp.getUi().alert('Select a row in the Contributions tab first.');return;} var first=sh.getActiveRange().getRow(),last=sh.getActiveRange().getLastRow();if(first<2){SpreadsheetApp.getUi().alert('Select contribution rows, not the headings.');return;} var ui=SpreadsheetApp.getUi();var answer=ui.alert(kind==='received'?'Confirm payment received':'Cancel unpaid reservation',kind==='received'?'Only continue after you have checked that the cash or Venmo payment actually arrived. This records the reserved amount as received. The saved EUR value is a planning equivalent. Record any actual exchange difference separately; do not overwrite the guest USD snapshot.':'This only releases an unpaid reservation; it does not refund a payment.',ui.ButtonSet.OK_CANCEL);if(answer!==ui.Button.OK)return; var lock=LockService.getScriptLock();lock.waitLock(10000);try{var rows=objects_(sh),count=0;rows.forEach(function(c){if(c._row>=first&&c._row<=last&&c.state==='pending'){mutate_(ss,c,{state:kind,updatedAt:new Date().toISOString()});audit_(ss,kind,c.id,'Owner action');count++;}});SpreadsheetApp.flush();ui.alert(count+' contribution(s) updated.');}finally{lock.releaseLock();} } function confirmSelectedPayments(){selectedAction_('received');} function cancelSelectedPending(){selectedAction_('cancelled');} /* SEED_GIFTS is appended by build_setup.py. */ var SEED_GIFTS = [{"id": "espresso", "title": "Morning espresso", "category": "Coffee & tea", "model": "Sage Bambino · SES450BSS", "description": "A little morning ritual, and a very happy start to our day.", "targetCents": 32999, "quantity": 1, "priority": true, "storeUrl": "https://www.abx.bg/product/8083/espreso-mashina-sage-ses450bss-the-bambino.html", "image": "assets/espresso.webp", "visible": true, "purchased": false, "note": "", "order": 1, "receivedCents": 0, "pledgedCents": 0}, {"id": "vacuum", "title": "A fresh, clean home", "category": "Home & laundry", "model": "Bosch Series 6 · BGL6POW2", "description": "A practical favourite for keeping our little home feeling lovely.", "targetCents": 21900, "quantity": 1, "priority": true, "storeUrl": "https://www.technopolis.bg/bg/Prahosmukachki-s-torba/Prahosmukachka-BOSCH-PRO-POWER-SERIES-6-BGL6POW2/p/812954", "image": "assets/vacuum.webp", "visible": true, "purchased": false, "note": "", "order": 2, "receivedCents": 0, "pledgedCents": 0}, {"id": "grinder", "title": "Freshly ground coffee", "category": "Coffee & tea", "model": "Sage Dose Control Pro · BCG600SIL", "description": "Fresh beans, ground just before we pull our espresso.", "targetCents": 23008, "quantity": 1, "priority": false, "storeUrl": "https://aromacoffee.bg/produkt/elektricheska-kafemelachka-sage-bcg600sil/", "image": "assets/grinder.webp", "visible": true, "purchased": false, "note": "", "order": 3, "receivedCents": 0, "pledgedCents": 0}, {"id": "kettle", "title": "Put the kettle on", "category": "Coffee & tea", "model": "Russell Hobbs Luna Stone · 26960-70", "description": "For slow mornings, a comforting cup of tea, and friends dropping by.", "targetCents": 3000, "quantity": 1, "priority": false, "storeUrl": "https://russellhobbs.bg/elektricheska-kana-russell-hobbs-luna-stone-26960-70/", "image": "assets/kettle.webp", "visible": true, "purchased": false, "note": "Working style option; the model may change.", "order": 4, "receivedCents": 0, "pledgedCents": 0}, {"id": "toaster", "title": "Toast for two", "category": "Kitchen", "model": "Ariete Vintage · 0155/03", "description": "A warm, vintage touch for everyday breakfasts together.", "targetCents": 6100, "quantity": 1, "priority": false, "storeUrl": "https://www.alleop.bg/toster-za-khlyab-ariete-vintage-0155-03-bezhov/p", "image": "assets/toaster.webp", "visible": true, "purchased": false, "note": "Working vintage option; the model may change.", "order": 5, "receivedCents": 0, "pledgedCents": 0}, {"id": "air-fryer", "title": "Easy weeknight dinners", "category": "Kitchen", "model": "Ninja Max Pro · AF180EU", "description": "For quick, delicious meals on the days we spend together at home.", "targetCents": 15900, "quantity": 1, "priority": false, "storeUrl": "https://www.citytel.bg/p/frityurnik-s-goresht-vazduh-ninja-max-pro-af180eu/", "image": "assets/air-fryer.webp", "visible": true, "purchased": false, "note": "", "order": 6, "receivedCents": 0, "pledgedCents": 0}, {"id": "iron", "title": "Looking our best", "category": "Home & laundry", "model": "Philips · DST3011/20", "description": "A simple, practical steam iron for everyday life.", "targetCents": 3200, "quantity": 1, "priority": false, "storeUrl": "https://www.technopolis.bg/bg/Yutii/Yutiya-PHILIPS-SERIES--3000-DST3011-20/p/821257", "image": "assets/iron.webp", "visible": true, "purchased": false, "note": "", "order": 7, "receivedCents": 0, "pledgedCents": 0}, {"id": "scale", "title": "A pinch of precision", "category": "Kitchen", "model": "Beurer · KS 19", "description": "For favourite recipes and the new ones we will learn together.", "targetCents": 1550, "quantity": 1, "priority": false, "storeUrl": "https://www.technopolis.bg/bg/Kuhnenski-vezni/Kuhnenska-vezna-BEURER-KS-19/p/822949", "image": "assets/scale.webp", "visible": true, "purchased": false, "note": "", "order": 8, "receivedCents": 0, "pledgedCents": 0}, {"id": "measuring-jug", "title": "A glass measuring jug", "category": "Cooking & baking", "model": "Pyrex · 1 litre", "description": "A useful kitchen classic for baking and cooking.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 9, "receivedCents": 0, "pledgedCents": 0}, {"id": "glass-containers", "title": "Glass food containers", "category": "Cooking & baking", "model": "Pyrex Cook & Go · 0.8 / 1.9 litres", "description": "For leftovers, lunches, and keeping good things fresh.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 10, "receivedCents": 0, "pledgedCents": 0}, {"id": "cutting-boards", "title": "Wooden cutting boards", "category": "Cooking & baking", "model": "IKEA APTITLIG", "description": "One large and one small wooden board.", "targetCents": null, "quantity": 2, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 11, "receivedCents": 0, "pledgedCents": 0}, {"id": "laundry-hamper", "title": "Laundry hamper", "category": "Home & laundry", "model": "IKEA BRANÄS · working option", "description": "A home for laundry until washing day.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 12, "receivedCents": 0, "pledgedCents": 0}, {"id": "laundry-basket", "title": "Laundry basket", "category": "Home & laundry", "model": "IKEA TORKIS · working option", "description": "For carrying fresh laundry and the everyday bits in between.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 13, "receivedCents": 0, "pledgedCents": 0}, {"id": "kitchen-bin", "title": "Kitchen bin", "category": "Home & laundry", "model": "IKEA HÅLLBAR · working option", "description": "A practical bin for the kitchen.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 14, "receivedCents": 0, "pledgedCents": 0}, {"id": "bathroom-bin", "title": "Bathroom bin", "category": "Bath & bed", "model": "IKEA BROGRUND · working option", "description": "A small bin for a tidy bathroom.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 15, "receivedCents": 0, "pledgedCents": 0}, {"id": "office-bin", "title": "Office bin", "category": "Home & laundry", "model": "IKEA FNISS · working option", "description": "A simple bin for our desk area.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 16, "receivedCents": 0, "pledgedCents": 0}, {"id": "dish-rack", "title": "Dish drying rack", "category": "Kitchen", "model": "IKEA KVOT", "description": "For dishes after shared meals.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 17, "receivedCents": 0, "pledgedCents": 0}, {"id": "paper-towel-holder", "title": "Paper towel holder", "category": "Kitchen", "model": "Model to be selected", "description": "A little everyday helper for the kitchen counter.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 18, "receivedCents": 0, "pledgedCents": 0}, {"id": "bath-mat", "title": "A soft bath mat", "category": "Bath & bed", "model": "IKEA BACKMÅRA · working option", "description": "Something soft underfoot after a shower.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 19, "receivedCents": 0, "pledgedCents": 0}, {"id": "washcloths", "title": "Washcloths · set of 4", "category": "Bath & bed", "model": "IKEA GULVIAL · working option", "description": "Four soft washcloths for the bathroom.", "targetCents": null, "quantity": 4, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 20, "receivedCents": 0, "pledgedCents": 0}, {"id": "hand-towels", "title": "Hand towels · set of 4", "category": "Bath & bed", "model": "IKEA GULVIAL · working option", "description": "A fresh set for everyday use and visiting friends.", "targetCents": null, "quantity": 4, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 21, "receivedCents": 0, "pledgedCents": 0}, {"id": "bath-towels", "title": "Bath towels · set of 4", "category": "Bath & bed", "model": "IKEA GULVIAL · working option", "description": "Four lovely bath towels.", "targetCents": null, "quantity": 4, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 22, "receivedCents": 0, "pledgedCents": 0}, {"id": "ironing-board", "title": "Ironing board", "category": "Home & laundry", "model": "IKEA DÄNKA", "description": "A sturdy partner for our steam iron.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 23, "receivedCents": 0, "pledgedCents": 0}, {"id": "drying-rack", "title": "Clothes drying rack", "category": "Home & laundry", "model": "IKEA MULIG", "description": "For fresh laundry and air-dried favourites.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 24, "receivedCents": 0, "pledgedCents": 0}, {"id": "vase", "title": "A vase for fresh flowers", "category": "Finishing touches", "model": "IKEA KONSTFULL · working option", "description": "For flowers on the table and little reasons to celebrate.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Covers the decorative vase / vases entries; quantity to confirm. Working selection. Final product and price to be confirmed.", "order": 25, "receivedCents": 0, "pledgedCents": 0}, {"id": "fruit-bowl", "title": "A bowl for the table", "category": "Finishing touches", "model": "IKEA BLANDA MATT · working option", "description": "A simple wooden bowl for fruit and everyday colour.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 26, "receivedCents": 0, "pledgedCents": 0}, {"id": "knives", "title": "Kitchen knife set", "category": "Cooking & baking", "model": "Model to be selected", "description": "The right essentials for cooking together.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 27, "receivedCents": 0, "pledgedCents": 0}, {"id": "baking-sheets", "title": "Baking sheets · pair", "category": "Cooking & baking", "model": "Exact trays to be selected", "description": "Two baking sheets for cookies and favourite treats.", "targetCents": null, "quantity": 2, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 28, "receivedCents": 0, "pledgedCents": 0}, {"id": "cooling-racks", "title": "Cooling racks · pair", "category": "Cooking & baking", "model": "Model to be selected", "description": "Two racks for letting freshly baked treats cool.", "targetCents": null, "quantity": 2, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 29, "receivedCents": 0, "pledgedCents": 0}, {"id": "mixing-bowls", "title": "Mixing bowls · set of 3", "category": "Cooking & baking", "model": "Bergner Bake Lovers · working option", "description": "Three bowls for mixing, whisking, and baking.", "targetCents": null, "quantity": 3, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 30, "receivedCents": 0, "pledgedCents": 0}, {"id": "utensils", "title": "Everyday kitchen utensils", "category": "Cooking & baking", "model": "IKEA essentials · working selection", "description": "Whisk, spatula, wooden spoon, cheese grater, garlic press, measuring cups and spoons, and peeler.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 31, "receivedCents": 0, "pledgedCents": 0}, {"id": "water-glasses", "title": "Water glasses · set of 10", "category": "Table & hosting", "model": "IKEA POKAL / 365+ · working options", "description": "Ten glasses for everyday meals and a table full of friends.", "targetCents": null, "quantity": 10, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 32, "receivedCents": 0, "pledgedCents": 0}, {"id": "soup-bowls", "title": "Ceramic soup bowls · set of 8", "category": "Table & hosting", "model": "IKEA 365+ / DINERA · working options", "description": "Eight bowls for soup and comforting meals.", "targetCents": null, "quantity": 8, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 33, "receivedCents": 0, "pledgedCents": 0}, {"id": "dinner-plates", "title": "Ceramic dinner plates · set of 10", "category": "Table & hosting", "model": "IKEA 365+ / DINERA · working options", "description": "Ten plates for meals shared around our table.", "targetCents": null, "quantity": 10, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 34, "receivedCents": 0, "pledgedCents": 0}, {"id": "mugs", "title": "Ceramic mugs · set of 10", "category": "Table & hosting", "model": "IKEA · exact mugs to be selected", "description": "For coffee, tea, and having everyone over.", "targetCents": null, "quantity": 10, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 35, "receivedCents": 0, "pledgedCents": 0}, {"id": "mirror", "title": "A mirror for our home", "category": "Finishing touches", "model": "IKEA NISSEDAL · working option", "description": "A finishing touch for a space of our own.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 36, "receivedCents": 0, "pledgedCents": 0}, {"id": "throw", "title": "A cosy throw blanket", "category": "Bath & bed", "model": "IKEA LAPPKATTFOT · working option", "description": "For quiet evenings and curling up together.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 37, "receivedCents": 0, "pledgedCents": 0}, {"id": "bedspread", "title": "A lovely bedspread", "category": "Bath & bed", "model": "IKEA JAKOBSLILJA · working option", "description": "A cosy finishing touch; size and colour to be confirmed.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 38, "receivedCents": 0, "pledgedCents": 0}, {"id": "bed-linen", "title": "Cotton sheets & pillowcases", "category": "Bath & bed", "model": "IKEA DVALA · working option", "description": "Comfortable cotton bedding; bed size to be confirmed.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 39, "receivedCents": 0, "pledgedCents": 0}, {"id": "frying-pans", "title": "Frying pan set", "category": "Cooking & baking", "model": "IKEA 365+ · working option", "description": "Useful pans for everyday cooking; set contents to be confirmed.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 40, "receivedCents": 0, "pledgedCents": 0}, {"id": "cooking-pots", "title": "Cooking pot set", "category": "Cooking & baking", "model": "IKEA 365+ · working option", "description": "The essentials for soups, sauces, and family recipes.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 41, "receivedCents": 0, "pledgedCents": 0}, {"id": "baking-dishes", "title": "Baking dishes · pair", "category": "Cooking & baking", "model": "Approx. 23 × 33 cm / 9 × 13 in", "description": "Two dishes for bakes and meals to share; exact products to be selected.", "targetCents": null, "quantity": 2, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 42, "receivedCents": 0, "pledgedCents": 0}, {"id": "dessert-plates", "title": "Ceramic dessert plates · set of 10", "category": "Table & hosting", "model": "IKEA 365+ / DINERA · working options", "description": "Ten little plates for dessert and something sweet.", "targetCents": null, "quantity": 10, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 43, "receivedCents": 0, "pledgedCents": 0}, {"id": "cutlery", "title": "A cutlery set", "category": "Table & hosting", "model": "IKEA DRAGON · working option", "description": "For shared meals; final number of place settings to be confirmed.", "targetCents": null, "quantity": 1, "priority": false, "storeUrl": "", "image": "", "visible": true, "purchased": false, "note": "Working selection. Final product and price to be confirmed.", "order": 44, "receivedCents": 0, "pledgedCents": 0}];