homeflowjs 1.0.68 → 1.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { isFunction } from '../utils';
3
+
4
+ /**
5
+ * Custom hook to observe the intersection of a target element with the viewport.
6
+ *
7
+ * @param {Object} params - Parameters for the hook.
8
+ * @param {HTMLElement|React.RefObject} params.target - The target element or a React ref to observe.
9
+ * @param {IntersectionObserverInit} [params.options={}] - Options for the IntersectionObserver (e.g., root, rootMargin, threshold).
10
+ * @param {Function} [params.onIntersect] - Callback function to execute when the target element intersects with the viewport.
11
+ *
12
+ * @returns {boolean} - A boolean indicating whether the target element is currently intersecting with the viewport.
13
+ */
14
+ const useIntersectionObserver = ({ target, options = {}, onIntersect }) => {
15
+ const [isIntersecting, setIsIntersecting] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (!target) return;
19
+
20
+ let targetElement = target;
21
+ // If `target` is a ref, get the current DOM element
22
+ if (target?.current) {
23
+ targetElement = target.current;
24
+ }
25
+
26
+ const observer = new IntersectionObserver(
27
+ ([entry]) => {
28
+ const intersecting = entry.isIntersecting;
29
+ setIsIntersecting(intersecting);
30
+
31
+ // Call the callback if provided and isIntersecting is true
32
+ if (intersecting && isFunction(onIntersect)) {
33
+ onIntersect();
34
+ }
35
+ },
36
+ options
37
+ );
38
+
39
+ observer.observe(targetElement);
40
+
41
+ // Cleanup observer on component unmount
42
+ return () => {
43
+ observer.disconnect();
44
+ };
45
+ }, [target, options, onIntersect]);
46
+
47
+ return isIntersecting;
48
+ };
49
+
50
+ export default useIntersectionObserver;
@@ -1,8 +1,8 @@
1
- import { useEffect } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
2
  import { isFunction } from '../utils';
3
+ import useIntersectionObserver from './use-intersection-observer';;
3
4
 
4
- const scrollToTarget = (target, { yOffset = 0, additionalYOffset = 0 } = {}) => {
5
-
5
+ const getTargetElementHelper = (target) => {
6
6
  // If target is chunk ID
7
7
  let targetElement;
8
8
  if (!isNaN(target)) {
@@ -15,29 +15,38 @@ const scrollToTarget = (target, { yOffset = 0, additionalYOffset = 0 } = {}) =>
15
15
  if (!targetElement) targetElement = document.querySelector(`.${target}`);
16
16
  if (!targetElement) targetElement = document.querySelector(`#${target}`);
17
17
 
18
- if (targetElement) {
19
- let verticalOffset = 0;
20
- if (typeof yOffset === 'number') {
21
- verticalOffset = yOffset;
22
- } else if (isFunction(yOffset)) {
23
- const value = yOffset();
24
- if (typeof value === 'number') {
25
- verticalOffset = value;
26
- }
27
- }
28
- if(additionalYOffset) {
29
- verticalOffset = verticalOffset + parseInt(additionalYOffset, 10);
30
- }
18
+ return targetElement;
19
+ }
31
20
 
32
- let y = 0;
33
- const targetTop = targetElement.getBoundingClientRect().top;
34
- if(targetTop > 0) y =
35
- targetElement.getBoundingClientRect().top +
36
- window.pageYOffset +
37
- verticalOffset;
38
- window.scrollTo({ top: y, behavior: 'smooth' });
39
- history.scrollRestoration = 'manual';
21
+ const scrollToTarget = ({ targetElement, yOffset = 0, additionalYOffset = 0 } = {}, signal) => {
22
+ if(!targetElement) return;
23
+
24
+ let verticalOffset = 0;
25
+ if (typeof yOffset === 'number') {
26
+ verticalOffset = yOffset;
27
+ } else if (isFunction(yOffset)) {
28
+ const value = yOffset();
29
+ if (typeof value === 'number') {
30
+ verticalOffset = value;
31
+ }
40
32
  }
33
+ if(additionalYOffset) {
34
+ verticalOffset = verticalOffset + parseInt(additionalYOffset, 10);
35
+ }
36
+
37
+ /**
38
+ * Scroll to the target element
39
+ * Note: when using getBoundingClientRect(), the position of the div will
40
+ * change whenever you scroll on the page. To get the absolute position of
41
+ * the element on the page, not relative to the window,we need to add the
42
+ * number of scrolled pixels to the element’s window location using scrollY:
43
+ */
44
+ const rect = targetElement.getBoundingClientRect();
45
+ window.scroll({
46
+ top: rect.top + window.scrollY + verticalOffset,
47
+ behavior: 'smooth',
48
+ ...(signal && { signal: signal }), // This is used to abort the scroll via a trigger in the useIntersectionObserver
49
+ });
41
50
  };
42
51
 
43
52
  const getURLParams = (hash) => {
@@ -46,37 +55,111 @@ const getURLParams = (hash) => {
46
55
  return { target, yOffsetFromURLParams: yOffset };
47
56
  };
48
57
 
49
- function handleScrollToClick(options) {
50
- // eslint-disable-next-line func-names
51
- return function () {
52
- const { target, yOffsetFromURLParams } = getURLParams(this.hash);
53
- if (target) {
54
- scrollToTarget(target, {
55
- ...options,
56
- additionalYOffset: yOffsetFromURLParams,
57
- });
58
- }
59
- };
60
- }
61
58
 
59
+ /**
60
+ * Custom hook that provides functionality to scroll to a specific target element
61
+ * on the page, either based on URL hash parameters or click events to links with
62
+ * href="#scroll-to?target=". It also handles layout shifts caused by lazy-loaded components
63
+ * using an intersection observer to ensure accurate scrolling.
64
+ *
65
+ * Features:
66
+ * - Scrolls to a target element specified by a URL hash or click event.
67
+ * - Supports additional vertical offsets (static or dynamic).
68
+ * - Uses an AbortController to manage and stop scrolling when necessary.
69
+ * - Resolves layout shift issues by re-calculating the target position using an intersection observer.
70
+ * - Automatically attaches click event listeners to links with href="#scroll-to?target=",
71
+ * target can either be a element id, class name or chunk id.
72
+ * - Handles URL navigation with scroll-to hash parameters.
73
+ *
74
+ * @param {Object} options - Configuration options for scrolling behavior.
75
+ * @param {number|function} [options.yOffset] - Vertical offset for scrolling. Can be a number or a function.
76
+ * @param {number} [options.additionalYOffset] - Additional vertical offset for scrolling.
77
+ */
62
78
  const useScrollTo = (options) => {
79
+ const [currentTarget, setCurrentTarget] = useState(null);
80
+ const scrollAbortControllerRef = useRef(null);
81
+
82
+ const handleScrollTo = () => {
83
+ const { target, yOffsetFromURLParams } = getURLParams(window.location.hash);
84
+ const targetElement = getTargetElementHelper(target);
85
+
86
+ if(!targetElement) return;
87
+
88
+ const targetObj = {
89
+ targetElement,
90
+ additionalYOffset: yOffsetFromURLParams,
91
+ ...options,
92
+ }
93
+
94
+ // Update state so that the intersection observer can pick it up
95
+ setCurrentTarget(targetObj);
96
+
97
+ // AbortController to manage the scroll
98
+ const controller = new AbortController();
99
+ scrollAbortControllerRef.current = controller;
100
+
101
+ // Scroll to the target element;
102
+ scrollToTarget({ ...targetObj }, controller.signal);
103
+ }
104
+
105
+ /**
106
+ * How this useIntersectionObserver is being used:
107
+ * This fixes a bug with the scrollToTarget function
108
+ * where the getBoundingClientRect() method returns
109
+ * a different value between scroll to's. This is caused
110
+ * because of layout shift with lazy loaded components
111
+ * while scrolling.
112
+ *
113
+ * How it works:
114
+ * When the scrollToTarget is fired, the useIntersectionObserver
115
+ * will check if the target element is in the viewport. If it is
116
+ * it stops the window.scroll method in the scrollToTarget function
117
+ * stopping the scroll. This is done by using the AbortController.
118
+ * Then it scrollToTarget again, this in turn gets the most up to date
119
+ * getBoundingClientRect() value and scrolls to the target element.
120
+ *
121
+ */
122
+ useIntersectionObserver({
123
+ target: currentTarget?.targetElement,
124
+ onIntersect: () => {
125
+ if (scrollAbortControllerRef.current) {
126
+ scrollAbortControllerRef.current.abort(); // Stop the scroll
127
+ scrollAbortControllerRef.current = null; // Reset the controller
128
+ scrollToTarget({ ...currentTarget }); // Fire the scrollToTarget function again
129
+ }
130
+ }
131
+ });
132
+
133
+
134
+ // Handle onclick scroll to events
63
135
  useEffect(() => {
136
+ const clickEventController = new AbortController()
64
137
  const scrollToEls = document.querySelectorAll(
65
138
  '[href*="#scroll-to"], [href*="#/scroll-to"]'
66
139
  );
140
+
67
141
  scrollToEls.forEach((el) => {
68
142
  el.addEventListener(
69
143
  'click',
70
- handleScrollToClick(options),
144
+ (e) => {
145
+ e.preventDefault();
146
+ handleScrollTo();
147
+ },
148
+ { signal: clickEventController.signal }
71
149
  );
72
150
  });
73
151
 
74
- const { target, yOffsetFromURLParams } = getURLParams(window.location.hash);
75
- scrollToTarget(target, {
76
- ...options,
77
- additionalYOffset: yOffsetFromURLParams,
78
- });
152
+ //On click element event listeners cleanup
153
+ return () => {
154
+ clickEventController.abort()
155
+ }
79
156
  }, []);
157
+
158
+ // Handle when a user navigates to a URL with a scroll to hash
159
+ useEffect(() => {
160
+ handleScrollTo();
161
+ }, [])
162
+
80
163
  };
81
164
 
82
165
  export default useScrollTo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homeflowjs",
3
- "version": "1.0.68",
3
+ "version": "1.0.70",
4
4
  "sideEffects": [
5
5
  "modal/**/*",
6
6
  "user/default-profile/**/*",
@@ -4,65 +4,61 @@ import englishRates from '../../utils/stamp-duty-calculator/english-rates';
4
4
 
5
5
  import './stamp-duty-calculator.styles.scss';
6
6
 
7
- const DefaultCalculator = ({ purchasePrice, setPurchasePrice, firstTimeBuyer, additionalProperty, ukResident, }) => {
8
- const [effectiveRate, setEffectiveRate] = useState();
9
- const [totalRate, setTotalRate] = useState();
10
- const [rate1, setRate1] = useState();
11
- const [rate2, setRate2] = useState();
12
- const [rate3, setRate3] = useState();
13
- const [rate4, setRate4] = useState();
14
- const [rate5, setRate5] = useState();
15
-
16
- const property = Homeflow.get('property');
17
-
18
- const results = englishRates(property.price_value, firstTimeBuyer, additionalProperty, ukResident);
19
-
20
- const calculateStampDuty = (price) => {
21
- if (!price) {
22
- setRate1(0);
23
- setRate2(0);
24
- setRate3(0);
25
- setRate4(0);
26
- setRate5(0);
27
- setTotalRate(0);
28
- setEffectiveRate(Math.round(0));
29
-
30
- return null;
31
- }
7
+ const DefaultCalculator = ({
8
+ purchasePrice,
9
+ setPurchasePrice,
10
+ firstTimeBuyer,
11
+ additionalProperty,
12
+ ukResident,
13
+ buyToLet
14
+ }) => {
15
+ const [priceDisplay, setPriceDisplay] = useState(purchasePrice);
16
+ const results = englishRates(
17
+ purchasePrice,
18
+ (firstTimeBuyer && !buyToLet),
19
+ (additionalProperty || buyToLet),
20
+ ukResident,
21
+ );
22
+
23
+ const englandRatesList = Homeflow.get('stamp_duty_england');
24
+
25
+ const ratesToCalculateBasedOnTaxType = () => {
26
+ const baseRates = englandRatesList['base'];
27
+ const firstTimeBuyerRates = englandRatesList['first_time_buyer'];
28
+
29
+ if (firstTimeBuyer && !buyToLet) {
30
+ const priceLimit = firstTimeBuyerRates[firstTimeBuyerRates?.length - 1]
31
+ ?.['maximum_property_price'];
32
32
 
33
- const {
34
- rate1,
35
- rate2,
36
- rate3,
37
- rate4,
38
- rate5,
39
- effectiveRate,
40
- totalRate
41
- } = results;
42
-
43
- setRate1(rate1);
44
- setRate2(rate2);
45
- setRate3(rate3);
46
- setRate4(rate4);
47
- setRate5(rate5);
48
- setTotalRate(totalRate);
49
- setEffectiveRate(Math.round(effectiveRate * 10) / 10);
50
-
51
- return null;
52
- };
53
-
54
- useEffect(() => {
55
- if (property && property.price_value) {
56
- setPurchasePrice(property.price_value);
57
- calculateStampDuty(property.price_value);
33
+ if (priceLimit && purchasePrice > priceLimit) {
34
+ return baseRates;
35
+ } else {
36
+ return firstTimeBuyerRates;
37
+ }
58
38
  }
59
- }, [firstTimeBuyer, additionalProperty]);
60
39
 
61
- const handlePriceInputChange = ({ target: { value } }) => {
62
- const numberString = value.substring(1);
63
- const number = parseInt(numberString.replace(/,/g, ''), 10);
64
- setPurchasePrice(number);
65
- };
40
+ return baseRates;
41
+ }
42
+
43
+ const ratesList = ratesToCalculateBasedOnTaxType();
44
+
45
+ const handleInputUpdate = (val) => {
46
+ let transformValues = val;
47
+
48
+ // Only accept numbers
49
+ if(transformValues.toUpperCase() != transformValues.toLowerCase()) return;
50
+
51
+ // If errors, default to 0
52
+ if (transformValues === NaN) transformValues = 0;
53
+
54
+ // Hacky way to keep the £ and multiple , in the input box while ultimately passing a number
55
+ if (transformValues?.includes('£')) transformValues = transformValues.replace('£', '');
56
+ if (transformValues?.includes(',')) transformValues = transformValues.replaceAll(',', '');
57
+
58
+ transformValues = +transformValues;
59
+
60
+ setPriceDisplay(transformValues);
61
+ }
66
62
 
67
63
  return (
68
64
  <div id="stampResi" className="stamp-duty-calculator main">
@@ -72,8 +68,8 @@ const DefaultCalculator = ({ purchasePrice, setPurchasePrice, firstTimeBuyer, ad
72
68
  className="SD_money residential auto"
73
69
  type="text"
74
70
  placeholder="Purchase price"
75
- value={`£${purchasePrice ? Number(purchasePrice).toLocaleString() : ''}`}
76
- onChange={handlePriceInputChange}
71
+ value={`£${priceDisplay?.toLocaleString() || '0'}`}
72
+ onChange={e => handleInputUpdate(e.target.value)}
77
73
  />
78
74
  </div>
79
75
 
@@ -82,7 +78,7 @@ const DefaultCalculator = ({ purchasePrice, setPurchasePrice, firstTimeBuyer, ad
82
78
  type="button"
83
79
  className="SD_calculate btn residential"
84
80
  data-preselector=".main"
85
- onClick={() => calculateStampDuty(purchasePrice)}
81
+ onClick={() => setPurchasePrice(priceDisplay)}
86
82
  >
87
83
  Calculate
88
84
  </button>
@@ -95,135 +91,65 @@ const DefaultCalculator = ({ purchasePrice, setPurchasePrice, firstTimeBuyer, ad
95
91
  {' '}
96
92
  <span className="SD_result residential">
97
93
  £
98
- {Number(totalRate).toLocaleString()}
94
+ {Number(results?.totalRate || 0).toLocaleString()}
99
95
  </span>
100
96
  {' '}
101
97
  Stamp duty
102
98
  </p>
103
99
 
104
- {(firstTimeBuyer && property.price_value >= 625000) && (
105
- <p id="first_time_buyer_info">First time buyers purchasing property for more than £625,000 will not be entitled to any relief and will pay SDLT at the standard rates.</p>
106
- )}
107
-
108
100
  <div className="results-table-wrap">
109
101
  <p><strong>How this was calculated:</strong></p>
110
- {(firstTimeBuyer && property.price_value <= 625000) ? (
111
- <table className="SD_results_table residential">
102
+ <table className="SD_results_table residential">
112
103
  <thead>
113
104
  <tr>
114
105
  <th>Band</th>
115
- <th>Stamp Duty Rate</th>
106
+ <th>{`${!buyToLet ? 'Stamp Duty ' : ''}Rate`}</th>
107
+ {(additionalProperty && !firstTimeBuyer && !buyToLet) && (
108
+ <th>Additional Property Rate</th>
109
+ )}
116
110
  <th>Due</th>
117
111
  </tr>
118
112
  </thead>
119
113
  <tbody>
120
- <tr>
121
- <td>Between £0 and £425,000</td>
122
- <td> 0% </td>
123
- <td className="rate1">
124
- £
125
- {Number(rate1).toLocaleString()}
126
- </td>
127
- </tr>
128
- <tr>
129
- <td>Between £425,001 and £625,000</td>
130
- <td> 5% </td>
131
- <td className="rate3">
132
- £
133
- {Number(rate2).toLocaleString()}
134
- </td>
135
- </tr>
136
- <tr className="totals">
137
- <td>Total</td>
138
- <td className="effectiveRate">
139
- {effectiveRate}
140
- %
141
- </td>
142
- <td className="totalRate">
143
- £
144
- {Number(totalRate).toLocaleString()}
145
- </td>
146
- </tr>
114
+ {ratesList?.map((rate, index) => {
115
+ // long string builder
116
+ let titleForRate = `${rate?.maximum_property_price ? 'Between' : 'Over'}`;
117
+ titleForRate += ' ';
118
+ titleForRate += `£${(+rate?.minimum_property_price).toLocaleString()}`;
119
+ titleForRate += `${rate?.maximum_property_price ? ' and £' : ''}`;
120
+ titleForRate += `${(+rate?.maximum_property_price).toLocaleString() || ''}`;
121
+
122
+ return (
123
+ <tr key={rate.id}>
124
+ <td>
125
+ {titleForRate}
126
+ </td>
127
+ {buyToLet ? (
128
+ <td>
129
+ {`${(+rate?.base_sdlt_rate || 0) + (+rate?.additional_property_surcharge || 0)}%`}
130
+ </td>
131
+ ) : (
132
+ <td>
133
+ {`${rate?.base_sdlt_rate || 0}%`}
134
+ </td>
135
+ )}
136
+ {(additionalProperty && !firstTimeBuyer && !buyToLet) && (
137
+ <td className="additional_rate">
138
+ {`${+rate?.additional_property_surcharge}%`}
139
+ </td>
140
+ )}
141
+ <td className="rate1">
142
+ {`£${Number(results?.[`rate${index + 1}`]).toLocaleString()}`}
143
+ </td>
144
+ </tr>
145
+ );
146
+ })}
147
147
  </tbody>
148
148
  </table>
149
- ) : (
150
- <table className="SD_results_table residential">
151
- <thead>
152
- <tr>
153
- <th>Band</th>
154
- <th>Stamp Duty Rate</th>
155
- <th>Additional Property Rate</th>
156
- <th>Due</th>
157
- </tr>
158
- </thead>
159
- <tbody>
160
- <tr>
161
- <td>Between £0 and £250,000</td>
162
- <td> 0% </td>
163
- <td className='additional_rate'> 3% </td>
164
- <td className="rate1">
165
- £
166
- {Number(rate1).toLocaleString()}
167
- </td>
168
- </tr>
169
- {/* <tr>
170
- <td>Between £125,000 and £250,000</td>
171
- <td> 2% </td>
172
- <td className="rate2">
173
- £
174
- {Number(rate2).toLocaleString()}
175
- </td>
176
- </tr> */}
177
- <tr>
178
- <td>Between £250,001 and £925,000</td>
179
- <td> 5% </td>
180
- <td className='additional_rate'> 8% </td>
181
- <td className="rate3">
182
- £
183
- {Number(rate2).toLocaleString()}
184
- </td>
185
- </tr>
186
- <tr>
187
- <td>Between £925,000 and £1,500,000 </td>
188
- <td> 10% </td>
189
- <td className='additional_rate'> 13% </td>
190
- <td className="rate4">
191
- £
192
- {Number(rate3).toLocaleString()}
193
- </td>
194
- </tr>
195
- <tr>
196
- <td>Over £1,500,000 </td>
197
- <td> 12% </td>
198
- <td className='additional_rate'> 15% </td>
199
- <td className="rate5">
200
- £
201
- {Number(rate4).toLocaleString()}
202
- </td>
203
- </tr>
204
- <tr className="totals">
205
- <td>Total</td>
206
- <td className="effectiveRate">
207
- {effectiveRate}
208
- %
209
- </td>
210
- <td></td>
211
- <td className="totalRate">
212
- £
213
- {Number(totalRate).toLocaleString()}
214
- </td>
215
- </tr>
216
- </tbody>
217
- </table>
218
- )}
219
149
  </div>
220
150
  </div>
221
151
  </div>
222
152
  );
223
153
  };
224
154
 
225
- const mapStateToProps = (state) => ({
226
- themePreferences: state.app.themePreferences,
227
- });
228
-
229
- export default connect(mapStateToProps)(DefaultCalculator);
155
+ export default DefaultCalculator;
@@ -1,14 +1,16 @@
1
1
  import React, { useState } from 'react';
2
- import { connect } from 'react-redux';
3
2
  import DefaultCalculator from './default-calculator.component';
4
- import BTLCalculator from './btl-calculator.component';
5
3
 
6
4
  import './stamp-duty-calculator.styles.scss';
7
5
 
8
- const StampDutyCalculator = ({ scotland, themePreferences }) => {
6
+ const StampDutyCalculator = () => {
7
+ const property = Homeflow.get('property');
9
8
  const [type, setType] = useState('residential');
9
+ const placeholderValue = 250_000;
10
+ const [purchasePrice, setPurchasePrice] = useState(
11
+ (property && property?.price_value) ? property.price_value : placeholderValue
12
+ );
10
13
  const [purchaseType, setPurchaseType] = useState('firstTimeBuyer')
11
- const [purchasePrice, setPurchasePrice] = useState();
12
14
  const [firstTimeBuyer, setFirstTimeBuyer] = useState(false);
13
15
  const [additionalProperty, setAdditionalProperty] = useState(false);
14
16
 
@@ -21,6 +23,7 @@ const StampDutyCalculator = ({ scotland, themePreferences }) => {
21
23
  firstTimeBuyer={firstTimeBuyer}
22
24
  additionalProperty={additionalProperty}
23
25
  ukResident
26
+ buyToLet={type === 'btl'}
24
27
  />
25
28
  );
26
29
  }
@@ -55,7 +58,11 @@ const StampDutyCalculator = ({ scotland, themePreferences }) => {
55
58
  <button
56
59
  type="button"
57
60
  className={`stamp-duty-cal__type ${type === 'btl' ? 'selected' : ''}`}
58
- onClick={() => setType('btl')}
61
+ onClick={() => {
62
+ if (firstTimeBuyer) setFirstTimeBuyer(prev => !prev);
63
+ if (additionalProperty) setAdditionalProperty(prev => !prev);
64
+ setType('btl');
65
+ }}
59
66
  >
60
67
  Buy to Let
61
68
  </button>
@@ -81,13 +88,16 @@ const StampDutyCalculator = ({ scotland, themePreferences }) => {
81
88
  <label htmlFor="additionalProperty">Additional Property</label>
82
89
  </div>
83
90
  </div>
84
- {renderCalculator()}
91
+ <DefaultCalculator
92
+ purchasePrice={purchasePrice}
93
+ setPurchasePrice={setPurchasePrice}
94
+ firstTimeBuyer={firstTimeBuyer}
95
+ additionalProperty={additionalProperty}
96
+ ukResident
97
+ buyToLet={type === 'btl'}
98
+ />
85
99
  </div>
86
100
  );
87
101
  };
88
102
 
89
- const mapStateToProps = (state) => ({
90
- themePreferences: state.app.themePreferences,
91
- });
92
-
93
- export default connect(mapStateToProps)(StampDutyCalculator);
103
+ export default StampDutyCalculator;