Initial commit
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=https://api.thehideoutltd.com
|
||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB
|
||||
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=https://thehideout.tombutcher.work/api
|
||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm
|
||||
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
7
assets/airbnblogo.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 93 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1.07553,0,0,1.07553,-3.50531,-3.77271)">
|
||||
<path d="M8.098,59.923C7.873,60.513 7.775,60.628 8.046,60.01C7.428,61.423 6.721,62.925 6.014,64.514C5.557,65.521 5.099,66.618 4.642,67.808C4.635,67.826 4.628,67.844 4.622,67.863C3.356,71.464 2.977,74.872 3.464,78.377C3.465,78.382 3.465,78.387 3.466,78.392C4.544,85.743 9.444,91.918 16.205,94.667C18.757,95.745 21.406,96.239 24.153,96.239C24.938,96.239 25.92,96.144 26.705,96.046C29.875,95.661 33.143,94.609 36.314,92.785C39.642,90.917 42.838,88.346 46.322,84.742C49.819,88.344 53.071,90.914 56.317,92.78C59.49,94.607 62.761,95.661 65.935,96.046C66.72,96.144 67.701,96.239 68.486,96.239C71.244,96.239 73.999,95.737 76.462,94.656C83.27,91.92 88.062,85.688 89.162,78.471C89.931,75.031 89.561,71.588 88.284,67.951C88.271,67.915 88.258,67.88 88.243,67.845C87.793,66.765 87.342,65.595 86.892,64.604C86.185,63.014 85.478,61.512 84.86,60.099L84.756,59.976C78.627,46.652 72.054,33.15 65.126,19.826L64.842,19.261C64.141,17.946 63.44,16.544 62.739,15.141C62.726,15.115 62.712,15.089 62.698,15.064C61.751,13.36 60.791,11.571 59.284,9.865C56.063,5.859 51.428,3.661 46.497,3.661C41.477,3.661 36.951,5.854 33.638,9.67C33.629,9.68 33.62,9.69 33.612,9.701C32.196,11.399 31.151,13.188 30.208,14.887C30.194,14.912 30.18,14.938 30.167,14.963C29.466,16.366 28.765,17.769 28.063,19.084L27.778,19.654C20.945,32.965 14.289,46.454 8.164,59.767L8.091,59.913L8.098,59.923ZM76.02,63.748C76.066,63.963 76.144,64.149 76.244,64.309C76.921,65.68 77.52,67.206 78.199,68.572C78.596,69.519 79.006,70.328 79.25,71.14C79.251,71.144 79.253,71.149 79.254,71.153C79.871,73.158 80.118,75.083 79.81,77.088C79.808,77.1 79.806,77.113 79.804,77.125C79.278,81.033 76.644,84.411 72.962,85.914C72.957,85.916 72.953,85.918 72.948,85.92C71.138,86.674 69.175,86.892 67.213,86.666C65.248,86.43 63.284,85.794 61.24,84.615C61.231,84.61 61.221,84.604 61.212,84.599C58.512,83.099 55.825,80.853 52.773,77.647C58.212,70.701 61.512,64.313 62.811,58.595C62.812,58.594 62.812,58.593 62.812,58.592C63.488,55.599 63.581,52.896 63.291,50.386C63.288,50.357 63.284,50.328 63.279,50.299C62.876,47.779 61.964,45.462 60.553,43.445C57.439,38.928 52.232,36.29 46.409,36.29C40.589,36.29 35.382,39.019 32.272,43.434C32.27,43.435 32.269,43.437 32.268,43.439C30.855,45.457 29.941,47.776 29.538,50.299C29.537,50.303 29.537,50.307 29.536,50.311C29.144,52.861 29.237,55.705 30.018,58.647C31.319,64.34 34.699,70.798 40.063,77.74C37.057,80.945 34.303,83.189 31.605,84.688C31.596,84.693 31.587,84.698 31.577,84.704C29.534,85.882 27.572,86.518 25.608,86.755C23.562,86.98 21.591,86.679 19.846,85.999C16.173,84.495 13.548,81.129 13.016,77.234C12.786,75.309 12.944,73.388 13.712,71.238C13.724,71.205 13.735,71.172 13.744,71.14C13.991,70.318 14.409,69.5 14.82,68.516C15.432,67.117 16.131,65.632 16.83,64.146C16.501,64.847 16.583,64.635 16.894,64.018L16.896,64.016L16.894,64.018L16.924,63.957C23.03,50.774 29.577,37.324 36.39,24.229L36.659,23.691C37.365,22.368 38.07,20.958 38.775,19.636C38.783,19.621 38.791,19.605 38.799,19.59C39.431,18.325 40.135,17.135 41.003,16.106C42.475,14.429 44.424,13.513 46.586,13.513C48.745,13.513 50.692,14.426 52.158,16.093C53.033,17.13 53.739,18.322 54.373,19.59C54.381,19.605 54.389,19.621 54.397,19.636C55.103,20.961 55.81,22.373 56.516,23.698C56.512,23.691 56.767,24.2 56.767,24.2C56.769,24.205 56.772,24.21 56.774,24.215C63.451,37.304 69.953,50.656 76.02,63.748ZM46.398,69.926C42.725,64.959 40.299,60.313 39.407,56.303C39.026,54.55 38.931,53.026 39.16,51.653C39.165,51.626 39.168,51.599 39.172,51.572C39.306,50.499 39.71,49.56 40.243,48.751C41.586,46.855 43.83,45.699 46.409,45.699C48.969,45.699 51.283,46.744 52.529,48.682C52.535,48.691 52.541,48.701 52.547,48.71C53.093,49.528 53.509,50.481 53.645,51.572C53.649,51.599 53.653,51.626 53.657,51.653C53.885,53.022 53.794,54.617 53.414,56.29C53.413,56.29 53.413,56.291 53.413,56.291C52.52,60.238 50.085,64.888 46.398,69.926Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
17
assets/bookingcomlogo.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1033 1197" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Artboard1" transform="matrix(0.837254,0,0,1,0,0)">
|
||||
<rect x="0" y="0" width="1233.14" height="1196.77" style="fill:none;"/>
|
||||
<g transform="matrix(1.19438,0,0,1,-829.044,-624.116)">
|
||||
<g>
|
||||
<g transform="matrix(0.820417,0,0,0.820417,126.517,219.541)">
|
||||
<path d="M1136.2,1618.37L934.84,1618.17L934.84,1377.4C934.84,1325.96 954.795,1299.18 998.817,1293.07L1136.2,1293.07C1234.17,1293.07 1297.54,1354.84 1297.54,1454.82C1297.54,1557.5 1235.77,1618.27 1136.2,1618.37L1136.2,1618.37ZM934.84,1107.86L934.84,905.596C934.84,850.142 958.304,823.769 1009.75,820.36L1112.83,820.36C1201.18,820.36 1254.12,873.206 1254.12,961.751C1254.12,1029.14 1217.82,1107.86 1116.04,1107.86L934.84,1107.86ZM1393.31,1209.24L1356.91,1188.78L1388.7,1161.6C1425.7,1129.82 1487.67,1058.32 1487.67,934.977C1487.67,746.054 1341.16,624.217 1114.44,624.217L855.72,624.217L855.72,624.116L826.239,624.116C759.053,626.623 705.204,681.275 704.502,748.862L704.502,1820.83L1119.55,1820.83C1371.55,1820.83 1534.2,1683.65 1534.2,1471.16C1534.2,1356.74 1481.65,1258.97 1393.31,1209.24" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.820417,0,0,0.820417,126.517,219.541)">
|
||||
<path d="M1642.29,1672.75C1642.29,1590.86 1708.34,1524.61 1789.71,1524.61C1871.29,1524.61 1937.65,1590.86 1937.65,1672.75C1937.65,1754.53 1871.29,1820.89 1789.71,1820.89C1708.34,1820.89 1642.29,1754.53 1642.29,1672.75" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
30
assets/checkicon.svg
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 56 56"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
xmlns:serif="http://www.serif.com/"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="55.415"
|
||||
height="55.708"
|
||||
style="fill-opacity:0; stroke: none;"
|
||||
/>
|
||||
<g transform="matrix(0.863271,0,0,0.863271,3.86933,3.15986)">
|
||||
<path
|
||||
d="M21.134,55.708C22.759,55.708 24.041,55.029 24.932,53.671L54.306,7.879C54.964,6.849 55.227,5.987 55.227,5.139C55.227,2.987 53.714,1.503 51.54,1.503C49.998,1.503 49.102,2.021 48.166,3.49L21.009,46.634L7.015,28.64C6.104,27.434 5.149,26.929 3.799,26.929C1.567,26.929 0,28.491 0,30.648C0,31.575 0.346,32.511 1.126,33.458L17.316,53.715C18.394,55.063 19.56,55.708 21.134,55.708Z"
|
||||
style="fill-rule:nonzero;"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
10
assets/closeicon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(0.877296,0,0,0.877296,3.85308,4)">
|
||||
<path d="M64,0L0.167,63.833" style="fill:none;stroke-width:7.98px;"/>
|
||||
<g transform="matrix(-1,0,0,1,64.1675,0)">
|
||||
<path d="M64,0L0.167,63.833" style="fill:none;stroke-width:7.98px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
8
assets/lefticon.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
||||
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
||||
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
29
assets/loadingicon.svg
Normal file
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 64 64"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
class="th-icon"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"
|
||||
>
|
||||
|
||||
<g>
|
||||
<path
|
||||
d="M32,5.131C46.829,5.131 58.869,17.171 58.869,32"
|
||||
style="fill:none;stroke-width:7px;"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 32 32"
|
||||
to="360 32 32"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
35
assets/menuicon.svg
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg
|
||||
class="th-menu-icon open"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g class="th-line line1">
|
||||
<path
|
||||
d="M8 14 H56"
|
||||
stroke="black"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
<g class="th-line line2">
|
||||
<path
|
||||
d="M8 32 H56"
|
||||
stroke="black"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
<g class="th-line line3">
|
||||
<path
|
||||
d="M8 50 H56"
|
||||
stroke="black"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 880 B |
10
assets/righticon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
||||
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
||||
<g transform="matrix(-1,0,0,1,71.0587,0)">
|
||||
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
21
assets/scrollicon.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg height="35px" viewBox="0 0 72 120" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.779005,0,0,0.345762,-153.293,-560.61)">
|
||||
<path d="M238.374,1697.93L238.374,1721.67C238.374,1726.99 240.291,1731.31 242.653,1731.31C245.014,1731.31 246.932,1726.99 246.932,1721.67L246.932,1697.93C246.932,1692.61 245.014,1688.29 242.653,1688.29C240.291,1688.29 238.374,1692.61 238.374,1697.93Z" style="fill:var(--th-textColor);">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="0,0; 0,60; 0,0"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 0.8; 1"
|
||||
keySplines="0.25 0.1 0.25 1; 0.8 0.1 0.9 1"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
<g transform="matrix(0.779005,0,0,0.779005,-153.293,-1296.23)">
|
||||
<path d="M288.526,1709.82C288.526,1684.51 267.971,1663.95 242.653,1663.95C217.335,1663.95 196.78,1684.51 196.78,1709.82L196.78,1771.12C196.78,1796.43 217.335,1816.99 242.653,1816.99C267.971,1816.99 288.526,1796.43 288.526,1771.12C288.526,1771.12 288.526,1709.82 288.526,1709.82ZM280.182,1709.82L280.182,1771.12C280.182,1791.83 263.366,1808.65 242.653,1808.65C221.94,1808.65 205.124,1791.83 205.124,1771.12C205.124,1771.12 205.124,1709.82 205.124,1709.82C205.124,1689.11 221.94,1672.29 242.653,1672.29C263.366,1672.29 280.182,1689.11 280.182,1709.82Z" style="fill:var(--th-textColor);"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
38
eslint.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
37
index.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/assets/favicon.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="Airbnb co-hosting and property management."
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/assets/favicon192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<link rel="stylesheet" href="/global.css" />
|
||||
<link rel="stylesheet" href="/fonts.css" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>The Hideout</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="th-root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
main.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
72
package.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "thehideout-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"homepage": "https://thehideout.uk",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.8.1",
|
||||
"antd": "^5.24.6",
|
||||
"axios": "^1.6.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"framer-motion": "^12.6.3",
|
||||
"hamburger-react": "^2.5.2",
|
||||
"keycloak-js": "^26.1.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-particles": "^2.12.2",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-scroll": "^1.9.3",
|
||||
"react-turnstile": "^1.1.4",
|
||||
"sass": "^1.86.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite-plugin-svgo": "^2.0.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"web-vitals": "^4.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "npm run build && vite preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --skip-caching"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.24.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0"
|
||||
}
|
||||
}
|
||||
BIN
public/.DS_Store
vendored
Normal file
28
public/assets/favicon.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Artboard1" transform="matrix(1.05263,0,0,2.60071,0,0)">
|
||||
<rect x="0" y="0" width="1026" height="415.271" style="fill:none;"/>
|
||||
<clipPath id="_clip1">
|
||||
<rect x="0" y="0" width="1026" height="415.271"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g id="Logo" transform="matrix(0.0904761,-5.60808e-17,-1.38558e-16,-0.03662,-355.3,504.839)">
|
||||
<g transform="matrix(2.95313,-4.5225e-15,-8.04e-15,-5.25001,3927,13785.9)">
|
||||
<path d="M3840,540C3840,241.966 3409.84,0 2880,0L960,0C430.162,0 0,241.966 0,540L0,1620C0,1918.03 430.162,2160 960,2160L2880,2160C3409.84,2160 3840,1918.03 3840,1620L3840,540Z" style="fill:rgb(131,11,13);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.83175,-1.57772e-30,-1.57772e-30,3.83175,-32672,-26559.4)">
|
||||
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,1876.27,-1505.29)">
|
||||
<path d="M10500,13761C10385,13730 10166.9,13634.5 10148.9,13626.5C10131.9,13618.5 10074.9,13595.5 10022.9,13574.5C9970.94,13553.5 9795.44,13478.6 9764.44,13464.6L9709.44,13438.6L9712.44,13331.6C9715.44,13210.6 9714.44,13211.6 9798.44,13250.6C9822.44,13260.6 9998.94,13335.5 10059.9,13359.5C10120.9,13383.5 10378,13503 10521,13540C10626,13567 10780,13549 10902,13494C10951,13472 11056.5,13433.1 11193.5,13379.1C11339.5,13322.1 11370.5,13309.1 11449.5,13274.1C11469.5,13266.1 11488.5,13259.1 11492.5,13259.1C11496.5,13259.1 11525.5,13247.1 11556.5,13232.1C11588.5,13218.1 11616.5,13206.1 11620.5,13206.1C11623.5,13206.1 11640.5,13199.1 11659.5,13191.1C11677.5,13184.1 11708.5,13170.1 11729.5,13161.1C11749.5,13152.1 11789.5,13135.1 11818.5,13124.1C11847.5,13113.1 11902.5,13089.1 11941.5,13071.1C11981.5,13053.1 12015.8,13039.5 12018.5,13038.1C12100.2,12996.9 11859.8,13113 12196.8,12950C12285.8,12907 12362.8,12873 12367.8,12873C12373.8,12873 12377.8,12921 12377.8,12983L12377.8,13094L12259.8,13150C12153.1,13200.6 12111.5,13227.1 12086.5,13240.1L12038.5,13264.1L12038.5,13568.1C12038.5,13736.1 12037.5,13879.1 12036.5,13886.1C12034.5,13896.1 11997.5,13899.1 11878.5,13897.1L11723.5,13894.1L11720.5,13649.1L11718.5,13405.1L11689.5,13412.1C11673.5,13416.1 11599.5,13444.1 11524.5,13474.1C11306.5,13563.1 11215,13595 11120,13634C11024,13673 10932,13711 10915,13719C10864,13743 10680,13787 10633,13786C10609,13786 10549,13774 10500,13761Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,7054.61,-611.996)">
|
||||
<path d="M3927,11427C3927,10789 3929,10675 3943,10628C3961,10566 4002,10529 4069,10513C4130,10497 4306,10488 4384,10496L4452,10503L4452,10752L4390,10752C4302,10752 4277,10764 4263,10812C4255,10838 4253,10970 4255,11203L4258,11555L4299,11558C4392,11565 4444,11622 4450,11723L4454,11792L4254,11792L4251,11927C4248,12046 4244,12067 4225,12093C4186,12145 4146,12160 4032,12167L3927,12173L3927,11427Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,7054.61,-611.996)">
|
||||
<path d="M4512,12164C4508,12159 4505,11781 4505,11323L4505,10490L4670,10489L4835,10489L4832,10807C4829,11160 4838,11272 4878,11361C4929,11472 4991,11512 5114,11512C5240,11512 5316,11456 5357,11331C5378,11266 5380,11238 5384,10875L5388,10490L5713,10490L5709,10959C5706,11369 5703,11437 5687,11492C5634,11672 5537,11758 5345,11797C5244,11818 5163,11812 5073,11779C5004,11753 4922,11701 4878,11656C4861,11638 4844,11624 4840,11624C4836,11624 4832,11724 4831,11846C4828,12089 4824,12108 4767,12142C4735,12161 4526,12179 4512,12164Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/assets/favicon192.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
40
public/assets/logo-dark.svg
Normal file
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1026 317" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M10500,13761C10385,13730 9777,13525 9707,13494C9696,13488 9672,13479 9655,13473C9555,13440 8733,13121 8657,13087C8640,13080 8515,13028 8379,12972C8243,12917 8118,12865 8101,12857C8043,12831 7770,12716 7765,12716C7762,12716 7735,12704 7705,12691C7583,12636 7499,12600 7424,12570C7380,12553 7243,12495 7119,12442C6995,12389 6858,12332 6815,12315C6771,12297 6722,12277 6704,12269C6687,12261 6630,12238 6578,12217C6526,12196 6459,12167 6428,12153L6373,12127L6376,12020C6379,11899 6378,11900 6462,11939C6486,11949 6554,11978 6615,12002C6676,12026 6750,12057 6781,12071C6811,12085 6865,12108 6901,12123C6937,12138 6981,12157 6998,12165C7016,12172 7103,12209 7193,12247C7452,12354 8182,12662 8216,12678C8234,12686 8338,12729 8447,12773C8557,12818 8661,12860 8678,12868C8704,12880 9014,13002 9382,13146C9658,13253 10378,13503 10521,13540C10626,13567 10780,13549 10902,13494C10951,13472 11490,13252 11702,13167C11777,13137 11874,13099 11918,13081C11961,13064 12091,13012 12206,12966C12322,12920 12426,12879 12437,12874C12511,12842 12547,12827 12684,12773C12830,12716 12861,12703 12940,12668C12960,12660 12979,12653 12983,12653C12987,12653 13016,12641 13047,12626C13079,12612 13107,12600 13111,12600C13114,12600 13131,12593 13150,12585C13168,12578 13199,12564 13220,12555C13240,12546 13280,12529 13309,12518C13338,12507 13393,12483 13432,12465C13472,12447 13506,12432 13509,12432C13511,12432 13626,12383 13763,12322C14071,12188 14182,12137 14519,11974C14608,11931 14685,11897 14690,11897C14696,11897 14700,11945 14700,12007L14700,12118L14582,12174C14517,12204 14424,12249 14375,12272C14203,12353 14000,12446 13813,12528C13709,12573 13602,12621 13577,12634L13529,12658L13529,12962C13529,13130 13528,13273 13527,13280C13525,13290 13488,13293 13369,13291L13214,13288L13211,13043L13209,12799L13180,12806C13164,12810 13090,12838 13015,12868C12797,12957 12414,13112 12311,13153C12259,13174 12203,13197 12185,13204C12168,13211 12073,13249 11975,13288C11877,13326 11783,13365 11765,13372C11748,13380 11649,13420 11545,13461C11441,13502 11342,13542 11324,13550C11307,13558 11215,13595 11120,13634C11024,13673 10932,13711 10915,13719C10864,13743 10680,13787 10633,13786C10609,13786 10549,13774 10500,13761Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M3927,11427C3927,10789 3929,10675 3943,10628C3961,10566 4002,10529 4069,10513C4130,10497 4306,10488 4384,10496L4452,10503L4452,10752L4390,10752C4302,10752 4277,10764 4263,10812C4255,10838 4253,10970 4255,11203L4258,11555L4299,11558C4392,11565 4444,11622 4450,11723L4454,11792L4254,11792L4251,11927C4248,12046 4244,12067 4225,12093C4186,12145 4146,12160 4032,12167L3927,12173L3927,11427Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M4512,12164C4508,12159 4505,11781 4505,11323L4505,10490L4670,10489L4835,10489L4832,10807C4829,11160 4838,11272 4878,11361C4929,11472 4991,11512 5114,11512C5240,11512 5316,11456 5357,11331C5378,11266 5380,11238 5384,10875L5388,10490L5713,10490L5709,10959C5706,11369 5703,11437 5687,11492C5634,11672 5537,11758 5345,11797C5244,11818 5163,11812 5073,11779C5004,11753 4922,11701 4878,11656C4861,11638 4844,11624 4840,11624C4836,11624 4832,11724 4831,11846C4828,12089 4824,12108 4767,12142C4735,12161 4526,12179 4512,12164Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M7032,12164C7028,12159 7025,11781 7025,11323L7025,10490L7359,10490L7363,10891L7366,11293L7396,11359C7434,11440 7479,11481 7556,11504C7655,11532 7768,11503 7826,11434C7894,11353 7906,11262 7906,10839L7907,10490L8233,10490L8230,10965C8226,11428 8225,11442 8202,11513C8174,11597 8158,11624 8107,11680C8024,11768 7835,11826 7697,11805C7601,11790 7505,11746 7427,11679L7362,11624L7359,11842C7355,12082 7348,12112 7287,12143C7253,12161 7045,12177 7032,12164Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M8323,12163C8319,12159 8316,12100 8316,12031L8316,11907L8642,11907L8642,11986C8642,12128 8607,12159 8437,12166C8378,12168 8327,12167 8323,12163Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M9626,12164C9622,12159 9618,12035 9618,11887L9618,11619L9570,11673C9471,11787 9307,11835 9140,11799C9026,11774 8969,11744 8892,11665C8835,11607 8817,11580 8791,11512C8742,11384 8732,11320 8732,11135C8732,10946 8747,10864 8800,10759C8845,10671 8879,10629 8950,10577C9038,10512 9150,10478 9303,10471C9656,10456 9868,10594 9927,10880C9941,10948 9944,11048 9944,11498C9944,12097 9945,12085 9871,12133C9841,12153 9813,12159 9734,12165C9679,12169 9630,12168 9626,12164ZM9406,11507C9533,11480 9599,11375 9614,11177C9625,11033 9589,10890 9526,10822C9481,10774 9423,10752 9340,10752C9176,10752 9090,10846 9072,11043C9056,11218 9094,11381 9167,11451C9197,11480 9289,11518 9331,11518C9344,11518 9378,11514 9406,11507Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M13652,11401L13655,10630L13685,10585C13703,10558 13729,10534 13752,10524C13806,10502 13974,10488 14083,10496L14175,10503L14175,10752L14101,10752C14036,10752 14024,10755 14001,10778L13975,10804L13978,11179L13981,11555L14029,11562C14123,11575 14175,11639 14175,11739L14175,11792L13977,11792L13974,11925C13971,12046 13968,12063 13946,12094C13910,12144 13875,12157 13756,12165L13650,12173L13652,11401Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M6252,11803C5928,11751 5773,11513 5789,11093C5797,10880 5838,10752 5933,10640C6024,10535 6160,10474 6328,10462C6621,10442 6829,10563 6916,10802C6930,10839 6941,10873 6941,10878C6941,10892 6687,10891 6651,10878C6636,10872 6604,10851 6579,10831C6511,10775 6452,10752 6375,10752C6231,10752 6158,10827 6127,11007L6121,11046L6964,11046L6959,11196C6955,11284 6946,11371 6936,11408C6882,11607 6768,11730 6584,11786C6517,11806 6330,11815 6252,11803ZM6474,11520C6531,11494 6578,11435 6598,11362C6607,11329 6615,11296 6615,11289C6615,11280 6553,11277 6374,11277C6100,11277 6119,11269 6150,11369C6172,11440 6228,11504 6284,11524C6343,11544 6423,11543 6474,11520Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M10433,11797C10217,11752 10077,11612 10020,11386C10007,11333 10002,11269 10002,11141C10002,10943 10016,10870 10077,10745C10184,10524 10457,10414 10749,10475C10912,10508 11032,10599 11102,10742C11123,10785 11144,10836 11147,10855L11154,10890L11006,10887C10871,10884 10856,10881 10829,10859C10813,10846 10774,10818 10742,10796C10687,10758 10683,10757 10590,10757C10506,10757 10491,10760 10458,10783C10396,10827 10360,10885 10348,10962C10344,10988 10339,11017 10336,11028C10331,11045 10358,11046 10754,11049L11177,11051L11175,11167C11170,11454 11079,11648 10908,11739C10873,11758 10820,11780 10790,11788C10710,11810 10519,11815 10433,11797ZM10684,11520C10741,11494 10790,11436 10809,11370C10839,11269 10858,11277 10583,11277L10340,11277L10347,11316C10365,11416 10428,11500 10505,11527C10555,11544 10638,11541 10684,11520Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M11684,11802C11546,11778 11429,11711 11357,11613C11263,11485 11231,11345 11238,11094C11241,10982 11248,10904 11260,10862C11334,10594 11525,10458 11825,10458C12110,10459 12285,10575 12373,10825C12405,10913 12405,10918 12406,11125C12406,11313 12403,11343 12382,11418C12368,11464 12345,11526 12329,11555C12289,11629 12203,11710 12126,11748C12013,11804 11829,11826 11684,11802ZM11915,11502C11936,11493 11965,11476 11979,11463C12097,11355 12099,10947 11982,10822C11924,10759 11813,10734 11736,10766C11619,10815 11568,10928 11568,11141C11568,11333 11621,11459 11723,11504C11766,11523 11868,11522 11915,11502Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M8316,11792L8316,10490L8642,10490L8641,11091C8641,11675 8641,11693 8620,11727C8590,11777 8545,11792 8421,11792L8316,11792Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
|
||||
<path d="M12487,11332C12493,10833 12497,10794 12555,10687C12590,10622 12635,10583 12725,10538C12834,10482 12905,10466 13030,10465C13161,10465 13255,10486 13364,10539C13449,10580 13494,10625 13531,10706C13581,10814 13587,10887 13587,11357L13587,11792L13474,11791C13381,11791 13355,11788 13328,11771C13267,11734 13267,11738 13262,11282C13256,10828 13256,10832 13197,10776C13160,10742 13117,10729 13039,10729C12942,10728 12877,10760 12844,10825C12821,10872 12821,10876 12821,11332L12821,11792L12482,11792L12487,11332Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
131
public/data/pages.json
Normal file
@ -0,0 +1,131 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "home",
|
||||
"name": "Home",
|
||||
"content": [
|
||||
{
|
||||
"type": "title1",
|
||||
"text": "turning properties into <i>unforgettable</i> stays."
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "we take the stress out of hosting and property management. from professional guest communication and seamless check-ins to maintenance and cleaning, we handle every detail so your property shines and your guests feel at home. enjoy peace of mind while maximising your property's potential. we make hosting effortless and rewarding."
|
||||
}
|
||||
],
|
||||
"theme": "dark",
|
||||
"image1": "https://images.pexels.com/photos/20666872/pexels-photo-20666872.jpeg",
|
||||
"image2": null,
|
||||
"showScroll": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"slug": "about",
|
||||
"name": "About Us",
|
||||
"content": [
|
||||
{
|
||||
"type": "title2",
|
||||
"text": "Who we are"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "We are a creative team dedicated to building amazing web experiences. Our passion for design and development drives everything we do."
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "title3",
|
||||
"text": "What We Do"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "From concept to completion, we craft digital solutions that not only meet your needs but exceed your expectations."
|
||||
}
|
||||
],
|
||||
"theme": "light",
|
||||
"image1": "https://images.pexels.com/photos/5825527/pexels-photo-5825527.jpeg",
|
||||
"image2": "https://images.pexels.com/photos/8580720/pexels-photo-8580720.jpeg",
|
||||
"showScroll": false
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"slug": "services",
|
||||
"name": "Our Services",
|
||||
"content": [
|
||||
{
|
||||
"type": "title1",
|
||||
"text": "What We Offer"
|
||||
},
|
||||
{
|
||||
"type": "title2",
|
||||
"text": "Web Development"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "Custom websites and web applications built with modern technologies and best practices."
|
||||
},
|
||||
{
|
||||
"type": "title2",
|
||||
"text": "Mobile Apps"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"text": "Native and cross-platform mobile applications for iOS and Android devices."
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "title3",
|
||||
"text": "Ready to Get Started?"
|
||||
},
|
||||
{
|
||||
"type": "title4",
|
||||
"text": "Let's build something amazing together."
|
||||
}
|
||||
],
|
||||
"theme": "dark",
|
||||
"image1": "https://images.pexels.com/photos/5490353/pexels-photo-5490353.jpeg",
|
||||
"image2": null,
|
||||
"showScroll": false
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"slug": "contact",
|
||||
"name": "Contact",
|
||||
"theme": "light",
|
||||
"image1": "https://images.pexels.com/photos/32168965/pexels-photo-32168965.jpeg",
|
||||
"image2": "https://images.pexels.com/photos/20927256/pexels-photo-20927256.jpeg",
|
||||
"showScroll": false
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"slug": "thank-you",
|
||||
"title": "Thank You",
|
||||
"content": "Thank you for visiting The Hideout. We hope you enjoyed the smooth scrolling experience and look forward to working with you soon!",
|
||||
"theme": "dark",
|
||||
"image1": "https://images.pexels.com/photos/14750394/pexels-photo-14750394.jpeg",
|
||||
"image2": null,
|
||||
"showScroll": false
|
||||
}
|
||||
],
|
||||
"themes": [
|
||||
{
|
||||
"name": "dark",
|
||||
"backgroundColor": "#830B0D",
|
||||
"textColor": "#ffffff",
|
||||
"logo": "/assets/logo-light.svg"
|
||||
},
|
||||
{
|
||||
"name": "light",
|
||||
"backgroundColor": "#ffffff",
|
||||
"textColor": "#5E0809",
|
||||
"logo": "/assets/logo-dark.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
109
public/fonts.css
Normal file
@ -0,0 +1,109 @@
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
|
||||
font-weight: 700;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-weight: 200;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF-Pro-Rounded";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff2")
|
||||
format("woff2"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff")
|
||||
format("woff"),
|
||||
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.otf")
|
||||
format("opentype");
|
||||
}
|
||||
1257
public/global.css
Normal file
20
public/manifest.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"short_name": "Tom Butcher",
|
||||
"name": "TOM BUTCHER",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://cdn.tombutcher.work/favicon/favicon192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "https://cdn.tombutcher.work/favicon/favicon512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#E800B6",
|
||||
"background_color": "#2B0BFF"
|
||||
}
|
||||
13
public/silent-check-sso.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Silent Check SSO</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
138
readme.md
Normal file
@ -0,0 +1,138 @@
|
||||
|
||||
# The Hideout UI
|
||||
[](https://ci.tombutcher.work/job/tombutcher.work/job/tombutcher-ui/job/main/)
|
||||
|
||||
This is the front-end web application for **The Hideout** ([thehideoutltd.com](https://thehideoutltd.com)), built with **React.js** and hosted on **Cloudflare Pages**. The website showcases rental properties with beautiful image galleries, property details, booking integrations, and a contact form for inquiries.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 19**: Modern JavaScript library for building user interfaces
|
||||
- **Vite**: Fast build tool and development server
|
||||
- **Ant Design**: UI component library for polished design
|
||||
- **Framer Motion**: Animation library for smooth transitions
|
||||
- **Cloudflare Pages**: Static site hosting with global CDN
|
||||
- **Cloudflare Turnstile**: Bot protection for form submissions
|
||||
- **Keycloak**: Authentication and authorization
|
||||
- **SASS**: CSS preprocessor for advanced styling
|
||||
|
||||
## Features
|
||||
|
||||
- **Property Showcase**: Display rental properties with detailed information and features
|
||||
- **Image Galleries**: Responsive image carousels with blurhash placeholders for smooth loading
|
||||
- **Responsive Design**: Fully responsive layout that adapts to all screen sizes
|
||||
- **Contact Form**: Secure contact form with Cloudflare Turnstile protection
|
||||
- **Booking Integration**: Links to Airbnb and Booking.com for direct bookings
|
||||
- **Authentication**: Keycloak integration for secure user management
|
||||
- **Particle Effects**: Interactive background animations using tsParticles
|
||||
- **Performance**: Optimized with lazy loading, caching, and modern web practices
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before getting started, make sure you have the following installed:
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v18 or higher recommended)
|
||||
- [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/)
|
||||
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) (for deployment)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
Clone this repository to your local machine:
|
||||
|
||||
```bash
|
||||
git clone https://git.tombutcher.work/tom/thehideout-ui.git
|
||||
cd thehideout-ui
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
Install the necessary dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Ensure any necessary environment variables are set up for:
|
||||
- Cloudflare Turnstile site keys
|
||||
- Keycloak configuration
|
||||
- API endpoints
|
||||
|
||||
### 4. Running Locally
|
||||
|
||||
To start the development server locally:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The website will be available at `http://localhost:5173` (Vite default port).
|
||||
|
||||
### 5. Building for Production
|
||||
|
||||
To create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The optimized build will be output to the `./dist` directory.
|
||||
|
||||
### 6. Preview Production Build
|
||||
|
||||
To preview the production build locally:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy to Cloudflare Pages
|
||||
|
||||
Deploy using Wrangler CLI:
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
This will build the project and deploy it to Cloudflare Pages.
|
||||
|
||||
### Automated Deployment
|
||||
|
||||
This project uses Jenkins for CI/CD. The pipeline automatically:
|
||||
1. Checks out the code
|
||||
2. Installs dependencies
|
||||
3. Builds the project
|
||||
4. Deploys to Cloudflare Pages
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
thehideout-ui/
|
||||
├── src/
|
||||
│ ├── components/ # Reusable React components
|
||||
│ ├── contexts/ # React context providers
|
||||
│ ├── icons/ # Custom icon components
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── App.jsx # Main application component
|
||||
│ └── main.jsx # Application entry point
|
||||
├── public/ # Static assets
|
||||
├── dist/ # Production build output
|
||||
└── assets/ # Additional assets
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Resources
|
||||
|
||||
- [React](https://react.dev/)
|
||||
- [Vite](https://vite.dev/)
|
||||
- [Ant Design](https://ant.design/)
|
||||
- [Cloudflare Pages](https://developers.cloudflare.com/pages/)
|
||||
- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/)
|
||||
BIN
src/.DS_Store
vendored
Normal file
408
src/App.jsx
Normal file
@ -0,0 +1,408 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Alert } from "antd";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Element, scroller } from "react-scroll";
|
||||
import axios from "axios";
|
||||
import Page from "./components/Page";
|
||||
import Images from "./components/ImageSidebar";
|
||||
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
|
||||
import LoadingModal from "./components/LoadingModal";
|
||||
import { ActionProvider } from "./contexts/ActionContext";
|
||||
import SubPage from "./components/SubPage";
|
||||
import PropertyPage from "./components/PropertyPage";
|
||||
import { MenuProvider } from "./contexts/MenuContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import {
|
||||
SettingsProvider,
|
||||
useSettingsContext,
|
||||
} from "./contexts/SettingsContext";
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Component that handles image loading after API data is fetched
|
||||
const AppContent = ({ pages, properties, images }) => {
|
||||
const { loadImages } = useImageContext();
|
||||
const [loadedOnce, setLoadedOnce] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
|
||||
const [currentTheme, setCurrentTheme] = useState();
|
||||
const [currentProperty, setCurrentProperty] = useState(null);
|
||||
const [currentPageIdx, setCurrentPageIdx] = useState(0);
|
||||
const [contentVisible, setContentVisible] = useState(true);
|
||||
const [subPageVisible, setSubPageVisible] = useState(false);
|
||||
const locationRef = useRef();
|
||||
const previousPageRef = useRef(null);
|
||||
const currentPageRef = useRef(currentPage);
|
||||
const landingPages = pages.filter((page) => page.pageType == "landingPage");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const settings = useSettingsContext();
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
|
||||
// Load images when they become available
|
||||
useEffect(() => {
|
||||
if (!loadedOnce && images && images.length > 0) {
|
||||
loadImages(images);
|
||||
setLoadedOnce(true);
|
||||
}
|
||||
}, [images, loadImages, loadedOnce]);
|
||||
|
||||
const setPageTitle = (name) => {
|
||||
document.title = name ? `${name} - The Hideout` : "The Hideout";
|
||||
};
|
||||
|
||||
// Handle direct URL navigation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
locationRef.current = location.pathname;
|
||||
if (Object.keys(settings).length == 0) {
|
||||
return;
|
||||
}
|
||||
// Check if the URL matches /properties/:slug
|
||||
if (location.pathname.startsWith("/properties/")) {
|
||||
const propertySlug = location.pathname.split("/properties/")[1];
|
||||
if (propertySlug != "") {
|
||||
const property = properties.find((p) => p.slug === propertySlug);
|
||||
if (property) {
|
||||
// Set previousPage when navigating to a property
|
||||
if (currentPageRef.current.pageType !== "subPage") {
|
||||
previousPageRef.current = { ...currentPageRef.current };
|
||||
}
|
||||
|
||||
setCurrentProperty(property);
|
||||
setContentVisible(true);
|
||||
if (currentProperty != null || currentPage.pageType == "subPage") {
|
||||
setSubPageVisible(false);
|
||||
setTimeout(() => {
|
||||
setSubPageVisible(true);
|
||||
}, 500);
|
||||
} else {
|
||||
setSubPageVisible(true);
|
||||
}
|
||||
setPageTitle(property.name);
|
||||
} else {
|
||||
navigate(settings.redirects["404"]);
|
||||
setCurrentProperty(null); // property not found
|
||||
}
|
||||
return; // exit early since we're on a property page
|
||||
}
|
||||
}
|
||||
setCurrentProperty(null);
|
||||
if (pages.length > 0 && location.pathname !== "/") {
|
||||
const slug = location.pathname.slice(1); // Remove leading slash
|
||||
const page = pages.find((p) => p.slug === slug);
|
||||
|
||||
if (page) {
|
||||
// Set previousPage when navigating to a subpage
|
||||
if (
|
||||
page.pageType === "subPage" &&
|
||||
currentPageRef.current.pageType !== "subPage"
|
||||
) {
|
||||
previousPageRef.current = { ...currentPageRef.current };
|
||||
}
|
||||
|
||||
setCurrentPage(page);
|
||||
setCurrentPageIdx(pages.indexOf(page));
|
||||
setPageTitle(page.name);
|
||||
|
||||
if (page.pageType === "landingPage") {
|
||||
// Ensure DOM has rendered before attempting to scroll
|
||||
setSubPageVisible(false);
|
||||
requestAnimationFrame(() => {
|
||||
scroller.scrollTo(slug, {
|
||||
duration: 0,
|
||||
delay: 0,
|
||||
smooth: "easeInOutQuart",
|
||||
containerId: "app-container",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setContentVisible(false);
|
||||
if (currentPage.pageType == page.pageType) {
|
||||
setSubPageVisible(false);
|
||||
setTimeout(() => {
|
||||
setSubPageVisible(true);
|
||||
}, 500);
|
||||
} else {
|
||||
setSubPageVisible(true);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setContentVisible(true);
|
||||
}, 600);
|
||||
}
|
||||
} else {
|
||||
navigate(settings.redirects["404"]);
|
||||
}
|
||||
} else if (
|
||||
pages.length > 0 &&
|
||||
location.pathname === "/" &&
|
||||
settings?.redirects?.index != null
|
||||
) {
|
||||
console.log("settings.redirects", settings.redirects);
|
||||
const indexPage = pages.find((p) => p.slug === settings.redirects.index);
|
||||
// Default to index page
|
||||
setCurrentPage(indexPage);
|
||||
setCurrentPageIdx(0);
|
||||
setPageTitle(indexPage.name);
|
||||
navigate(`/${indexPage.slug}`, { replace: true });
|
||||
}
|
||||
}, [
|
||||
location.pathname,
|
||||
pages,
|
||||
settings?.redirects?.index,
|
||||
navigate,
|
||||
settings,
|
||||
properties,
|
||||
currentPage.pageType,
|
||||
currentProperty,
|
||||
]);
|
||||
|
||||
// Set up scroll spy to update URL when scrolling
|
||||
useEffect(() => {
|
||||
if (currentPage.pageType == "subPage") {
|
||||
return;
|
||||
}
|
||||
if (pages.length > 0) {
|
||||
let scrollTimeout;
|
||||
|
||||
const handleScrollSpy = () => {
|
||||
const container = document.getElementById("app-container");
|
||||
if (!container) return;
|
||||
|
||||
const scrollTop = container.scrollTop;
|
||||
const containerHeight = container.clientHeight;
|
||||
const viewportCenter = scrollTop + containerHeight / 2;
|
||||
|
||||
// Find which page is currently in the center of the viewport
|
||||
let currentPageIndex = 0;
|
||||
let minDistance = Infinity;
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
const element = document.querySelector(`[data-name="${page.slug}"]`);
|
||||
if (element) {
|
||||
const elementTop = element.offsetTop;
|
||||
//const elementBottom = elementTop + element.offsetHeight;
|
||||
const elementCenter = elementTop + element.offsetHeight / 2;
|
||||
|
||||
const distance = Math.abs(viewportCenter - elementCenter);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
currentPageIndex = index;
|
||||
if (minDistance != 0) {
|
||||
setContentVisible(false);
|
||||
}
|
||||
if (minDistance == 0) {
|
||||
setContentVisible(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Debounce both current page state and URL updates to reduce frequency
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
if (pages[currentPageIndex] && pages[currentPageIndex].slug) {
|
||||
// Update current page state for Images component
|
||||
if (currentPageIdx !== currentPageIndex) {
|
||||
setCurrentPage(pages[currentPageIndex]);
|
||||
setCurrentPageIdx(currentPageIndex);
|
||||
setPageTitle(pages[currentPageIndex].name);
|
||||
|
||||
const newPath = `/${pages[currentPageIndex].slug}`;
|
||||
if (locationRef.current !== newPath) {
|
||||
navigate(newPath, { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100); // Debounce for 100ms
|
||||
};
|
||||
|
||||
const container = document.getElementById("app-container");
|
||||
if (container) {
|
||||
container.addEventListener("scroll", handleScrollSpy, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScrollSpy);
|
||||
clearTimeout(scrollTimeout);
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [pages, navigate, currentPageIdx, currentPage.pageType]);
|
||||
|
||||
// Update currentPageRef whenever currentPage changes
|
||||
useEffect(() => {
|
||||
currentPageRef.current = currentPage;
|
||||
}, [currentPage]);
|
||||
|
||||
// Set body background color to match current page's theme
|
||||
useEffect(() => {
|
||||
const theme = settings.themes.find(
|
||||
(theme) => theme.name === currentPage?.theme
|
||||
);
|
||||
setCurrentTheme(theme);
|
||||
}, [currentPage, settings.themes]);
|
||||
|
||||
// Handle subpage close with smart navigation
|
||||
const handleSubPageClose = () => {
|
||||
// Check if there's a previous page stored in ref
|
||||
if (previousPageRef.current && previousPageRef.current.slug) {
|
||||
// Navigate to the previous page's slug
|
||||
navigate(`/${previousPageRef.current.slug}`);
|
||||
} else {
|
||||
// No previous page, redirect to home
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider currentTheme={currentTheme}>
|
||||
<MenuProvider pages={pages} currentPageSlug={currentPage?.slug}>
|
||||
<div
|
||||
className={
|
||||
!isMobile
|
||||
? "th-app-container"
|
||||
: "th-app-container th-app-container-mobile"
|
||||
}
|
||||
id="app-container"
|
||||
>
|
||||
{landingPages.map((pageData, index) => (
|
||||
<Element
|
||||
key={index}
|
||||
name={pageData.slug}
|
||||
className={
|
||||
!isMobile
|
||||
? "th-page-wrapper"
|
||||
: "th-page-wrapper th-page-wrapper-mobile"
|
||||
}
|
||||
data-name={pageData.slug}
|
||||
>
|
||||
<Page
|
||||
pageData={pageData}
|
||||
visible={
|
||||
contentVisible && pageData.notionId == currentPage.notionId
|
||||
}
|
||||
theme={settings.themes.find(
|
||||
(theme) => theme.name === pageData.theme
|
||||
)}
|
||||
themes={settings.themes}
|
||||
isSubPage={false}
|
||||
id={index}
|
||||
/>
|
||||
</Element>
|
||||
))}
|
||||
</div>
|
||||
<SubPage
|
||||
visible={currentPage.pageType == "subPage" && subPageVisible == true}
|
||||
>
|
||||
<Page
|
||||
pageData={currentPage}
|
||||
visible={contentVisible}
|
||||
theme={settings.themes.find(
|
||||
(theme) => theme.name === currentPage.theme
|
||||
)}
|
||||
themes={settings.themes}
|
||||
isSubPage={true}
|
||||
id={currentPageIdx}
|
||||
showClose={true}
|
||||
showMenu={false}
|
||||
onClose={handleSubPageClose}
|
||||
/>
|
||||
</SubPage>
|
||||
<SubPage visible={currentProperty != null && subPageVisible == true}>
|
||||
<PropertyPage
|
||||
propertyData={currentProperty}
|
||||
visible={contentVisible}
|
||||
onClose={() => {
|
||||
navigate("/" + settings.redirects.properties);
|
||||
}}
|
||||
/>
|
||||
</SubPage>
|
||||
{isMobile && <div className="th-mobile-overlay" />}
|
||||
|
||||
{!isMobile && (
|
||||
<Images
|
||||
imageCollections={[...pages, ...properties]}
|
||||
currentSlug={currentProperty?.slug || currentPage?.slug}
|
||||
/>
|
||||
)}
|
||||
</MenuProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
AppContent.propTypes = {
|
||||
images: PropTypes.shape({
|
||||
length: PropTypes.number,
|
||||
}),
|
||||
pages: PropTypes.array.isRequired,
|
||||
properties: PropTypes.array.isRequired,
|
||||
themes: PropTypes.shape({
|
||||
find: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [pages, setPages] = useState([]);
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
const [settings, setSettings] = useState({
|
||||
themes: [],
|
||||
redirects: {},
|
||||
branding: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const actions = {};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${apiUrl}/content`);
|
||||
setPages(response.data.pages || response.data); // Handle both array and object with pages property
|
||||
setProperties(response.data.properties || response.data);
|
||||
setImages(response.data.images || response.data);
|
||||
setSettings(response.data.settings || response.data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.response?.data?.message || err.message || "Failed to fetch pages"
|
||||
);
|
||||
console.error("Error fetching pages:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPages();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="th-error-container">
|
||||
<Alert
|
||||
message="Error Loading Pages"
|
||||
description={error}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsProvider settings={settings}>
|
||||
<ActionProvider onAction={actions}>
|
||||
<ImageProvider>
|
||||
<AppContent pages={pages} images={images} properties={properties} />
|
||||
<LoadingModal visible={loading} />
|
||||
</ImageProvider>
|
||||
</ActionProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
src/components/.DS_Store
vendored
Normal file
27
src/components/CloseButton.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import CloseIcon from "../icons/CloseIcon";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const CloseButton = ({ onClick }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
navigate(-1); // Goes back one page in the browser history
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="th-header-button" onClick={handleClick}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
CloseButton.propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CloseButton;
|
||||
127
src/components/ContactForm.jsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useState, useRef } from "react";
|
||||
import axios from "axios";
|
||||
import CheckIcon from "../icons/CheckIcon";
|
||||
import Turnstile from "./Turnstile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsContext } from "../contexts/SettingsContext.jsx";
|
||||
import LoadingIcon from "../icons/LoadingIcon.jsx";
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const turnstileKey = import.meta.env.VITE_TURNSTILE_KEY;
|
||||
|
||||
const ContactForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const settings = useSettingsContext();
|
||||
const turnstileRef = useRef(null); // Ref for Turnstile
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const [token, setToken] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token) {
|
||||
alert("Please complete the CAPTCHA");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Send formData + token to your backend using axios
|
||||
const response = await axios.post(`${apiUrl}/contact`, {
|
||||
...formData,
|
||||
token,
|
||||
});
|
||||
console.log(settings);
|
||||
navigate(`/${settings.redirects.contactFormComplete}`);
|
||||
console.log("Form submitted:", response.data);
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
const errorData = error.response.data;
|
||||
setError(errorData);
|
||||
if (errorData.code.startsWith("captcha-")) {
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="th-contact-form-wrapper">
|
||||
<form onSubmit={handleSubmit} className="th-form">
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="th-form-input"
|
||||
placeholder="your name"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="th-form-input"
|
||||
placeholder="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows="5"
|
||||
className="th-form-input"
|
||||
placeholder="write your message here..."
|
||||
disabled={loading}
|
||||
></textarea>
|
||||
|
||||
{/* Cloudflare Turnstile */}
|
||||
<Turnstile
|
||||
siteKey={turnstileKey}
|
||||
theme="light"
|
||||
ref={turnstileRef}
|
||||
onVerify={(token) => setToken(token)}
|
||||
/>
|
||||
|
||||
<div className="th-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className={"th-button"}
|
||||
style={{ marginTop: 0 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <LoadingIcon /> : <CheckIcon />}
|
||||
Send Message
|
||||
</button>
|
||||
{error && <p className="th-form-error">{error.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
185
src/components/ContentRenderer.jsx
Normal file
@ -0,0 +1,185 @@
|
||||
import ContactForm from "./ContactForm";
|
||||
import PropertyCards from "./PropertyCards";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const onLinkClick = (href, navigate) => {
|
||||
const origin = window.location.origin;
|
||||
|
||||
if (href.startsWith(origin)) {
|
||||
// Strip origin, keep only path + query + hash
|
||||
const relativePath = href.slice(origin.length) || "/";
|
||||
navigate(relativePath);
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextClick = (e, navigate) => {
|
||||
if (e.target.tagName === "A") {
|
||||
e.preventDefault();
|
||||
onLinkClick(e.target.href, navigate);
|
||||
}
|
||||
if (e.target.parentNode.tagName === "A") {
|
||||
e.preventDefault();
|
||||
onLinkClick(e.target.parentNode.href, navigate);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to render different content types
|
||||
const renderContentElement = (element, index, navigate) => {
|
||||
const { type, text } = element;
|
||||
|
||||
switch (type) {
|
||||
case "title1":
|
||||
return (
|
||||
<h1
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
className="th-title"
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "title2":
|
||||
return (
|
||||
<h2
|
||||
key={index}
|
||||
className="th-title"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "title3":
|
||||
return (
|
||||
<h3
|
||||
key={index}
|
||||
className="th-title"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "title4":
|
||||
return (
|
||||
<h4
|
||||
key={index}
|
||||
className="th-title"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p
|
||||
key={index}
|
||||
className="th-paragraph"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "divider":
|
||||
return <hr key={index} className="th-divider" />;
|
||||
case "list": {
|
||||
const { ordered, children = [] } = element;
|
||||
const ListTag = ordered ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag
|
||||
key={index}
|
||||
className={
|
||||
ordered ? "th-list th-orderedList" : "th-list th-unorderedList"
|
||||
}
|
||||
>
|
||||
{children.map((child, childIdx) =>
|
||||
renderContentElement(child, childIdx),
|
||||
)}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listItem": {
|
||||
// Support for nested lists inside list items
|
||||
const { text, children = [] } = element;
|
||||
return (
|
||||
<li key={index} className="th-listItem">
|
||||
{text && <span dangerouslySetInnerHTML={{ __html: text }} />}
|
||||
{children.length > 0 &&
|
||||
children.map((child, childIdx) =>
|
||||
renderContentElement(child, childIdx),
|
||||
)}
|
||||
{/* If children are lists, they will be rendered here */}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
case "button":
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
const linkUrl = new URL(element.url, window.location.origin);
|
||||
|
||||
if (linkUrl.origin === window.location.origin) {
|
||||
// Same origin → navigate with React Router
|
||||
navigate(linkUrl.pathname + linkUrl.search + linkUrl.hash);
|
||||
} else {
|
||||
// Different origin → full redirect
|
||||
window.location.href = element.url;
|
||||
}
|
||||
}}
|
||||
className="th-button"
|
||||
dangerouslySetInnerHTML={{ __html: `${element?.icon || ""}${text}` }}
|
||||
/>
|
||||
);
|
||||
case "properties":
|
||||
return <PropertyCards properties={element.properties} />;
|
||||
case "contactForm":
|
||||
return <ContactForm />;
|
||||
default:
|
||||
return (
|
||||
<p
|
||||
key={index}
|
||||
className="th-paragraph"
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
onClick={(e) => {
|
||||
handleTextClick(e, navigate);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ContentRenderer = ({ content }) => {
|
||||
const navigate = useNavigate();
|
||||
// Handle both old format (string) and new format (array of objects)
|
||||
const renderContent = () => {
|
||||
if (!content) {
|
||||
return (
|
||||
<p className="th-content-default">Default content for this page.</p>
|
||||
);
|
||||
}
|
||||
|
||||
// If content is a string (old format), render it as a paragraph
|
||||
if (typeof content === "string") {
|
||||
return <p className="th-content-default">{content}</p>;
|
||||
}
|
||||
|
||||
// If content is an array (new format), render each element
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((element, index) =>
|
||||
renderContentElement(element, index, navigate),
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="th-content-default">Invalid content format.</p>;
|
||||
};
|
||||
|
||||
return renderContent();
|
||||
};
|
||||
|
||||
export default ContentRenderer;
|
||||
49
src/components/FeaturesList.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const FeaturesList = ({ features, maxVisible = 7 }) => {
|
||||
if (!features || !Array.isArray(features)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Simple heuristic: assume each feature takes about 60px (including padding/gap)
|
||||
// 2 rows would be approximately 120px, so show counter if we have more than 3-4 features
|
||||
const estimatedMaxVisible = maxVisible;
|
||||
const showOverflowCounter = features.length > estimatedMaxVisible;
|
||||
|
||||
if (maxVisible == null) {
|
||||
return (
|
||||
<div className="th-features-list">
|
||||
{features.map((feature, i) => (
|
||||
<div key={i} className="th-features-listItem">
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const visibleFeatures = showOverflowCounter
|
||||
? features.slice(0, estimatedMaxVisible - 1) // Leave space for counter
|
||||
: features;
|
||||
|
||||
return (
|
||||
<div className="th-features-list">
|
||||
{visibleFeatures.map((feature, i) => (
|
||||
<div key={i} className="th-features-listItem">
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
{showOverflowCounter && (
|
||||
<div className="th-features-listItem th-features-overflow-counter">
|
||||
+{features.length - visibleFeatures.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeaturesList.propTypes = {
|
||||
features: PropTypes.array,
|
||||
maxVisible: PropTypes.number,
|
||||
};
|
||||
|
||||
export default FeaturesList;
|
||||
53
src/components/HeaderLogo.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
||||
|
||||
const HeaderLogo = ({ large = false, visible = true }) => {
|
||||
const [currentLogo, setCurrentLogo] = useState(null);
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
const settings = useSettingsContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.branding.length > 0) {
|
||||
const largeLogo = settings.branding.find(
|
||||
(brandingItem) =>
|
||||
brandingItem.type === "logo" && brandingItem.size === "large",
|
||||
);
|
||||
const smallLogo = settings.branding.find(
|
||||
(brandingItem) =>
|
||||
brandingItem.type === "logo" && brandingItem.size === "small",
|
||||
);
|
||||
|
||||
if (large) {
|
||||
setCurrentLogo(largeLogo.content);
|
||||
} else {
|
||||
setCurrentLogo(smallLogo.content);
|
||||
}
|
||||
}
|
||||
}, [large, isMobile, settings.branding]);
|
||||
|
||||
return (
|
||||
<header className="th-header-logo">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: currentLogo }}
|
||||
alt="The Hideout Logo"
|
||||
className={`th-logo-image th-logo-current ${
|
||||
large && !isMobile ? "th-logo-image-large" : ""
|
||||
} ${large && isMobile ? "th-logo-image-large-mobile" : ""}
|
||||
${!visible ? "th-logo-image-hidden" : ""}`}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
HeaderLogo.propTypes = {
|
||||
currentTheme: PropTypes.string,
|
||||
large: PropTypes.bool,
|
||||
themes: PropTypes.shape({
|
||||
find: PropTypes.func,
|
||||
}),
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default HeaderLogo;
|
||||
106
src/components/Image.jsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { decode } from "blurhash";
|
||||
import { useImageContext } from "../contexts/ImageContext";
|
||||
|
||||
const Image = ({ src, alt, className, loading = "lazy", ...props }) => {
|
||||
const { imageObjects } = useImageContext();
|
||||
|
||||
const [blurhashCanvas, setBlurhashCanvas] = useState(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [currentImageObj, setCurrentImageObj] = useState(null);
|
||||
const processedSrcRef = useRef(null);
|
||||
|
||||
// Find the image object that matches the src
|
||||
useEffect(() => {
|
||||
if (src) {
|
||||
const imageObj = imageObjects.find((img) => img.src === src);
|
||||
setCurrentImageObj(imageObj || null);
|
||||
// Reset processed state when src changes
|
||||
if (processedSrcRef.current !== src) {
|
||||
processedSrcRef.current = src;
|
||||
setImageLoaded(false);
|
||||
}
|
||||
} else {
|
||||
setCurrentImageObj(null);
|
||||
processedSrcRef.current = null;
|
||||
setImageLoaded(false);
|
||||
}
|
||||
}, [src, imageObjects]);
|
||||
|
||||
// Generate blurhash canvas when blurhash is available
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentImageObj?.blurHash &&
|
||||
!blurhashCanvas &&
|
||||
processedSrcRef.current === src
|
||||
) {
|
||||
try {
|
||||
const pixels = decode(currentImageObj.blurHash, 32, 32);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.createImageData(32, 32);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
setBlurhashCanvas(canvas);
|
||||
} catch (error) {
|
||||
console.error("Failed to decode blurhash:", error);
|
||||
}
|
||||
}
|
||||
}, [currentImageObj?.blurHash, blurhashCanvas, src]);
|
||||
|
||||
// Handle image load
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
// Handle image error
|
||||
const handleImageError = () => {
|
||||
setImageLoaded(false);
|
||||
};
|
||||
|
||||
if (currentImageObj) {
|
||||
return (
|
||||
<div className={`th-image-container ${className || ""}`}>
|
||||
{blurhashCanvas ? (
|
||||
<img
|
||||
src={blurhashCanvas.toDataURL()}
|
||||
alt={alt}
|
||||
className="th-image-blurhash"
|
||||
/>
|
||||
) : null}
|
||||
{currentImageObj.blob && (
|
||||
<img
|
||||
src={currentImageObj.blob}
|
||||
alt={alt}
|
||||
className={`th-image-content ${className}`}
|
||||
loading={loading}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
style={{
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
...props.style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (currentImageObj && currentImageObj.loadingState === "error") {
|
||||
return <div className="th-image-error">Failed to load image</div>;
|
||||
}
|
||||
|
||||
// Fallback loading state
|
||||
if (currentImageObj && currentImageObj.loadingState === "loading") {
|
||||
return <div className="th-image-loading">Loading image...</div>;
|
||||
}
|
||||
|
||||
// No src provided or no matching image object
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Image;
|
||||
58
src/components/ImageCarousel.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "./Image";
|
||||
|
||||
const ImageCarousel = ({ page, interval = 8000, className }) => {
|
||||
const images = page.images || [];
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// Cycle through images
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [images, interval]);
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="th-no-images">
|
||||
<p>No images available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`th-image-carousel ${className}`}>
|
||||
{images.map((src, index) => (
|
||||
<div
|
||||
key={`carousel-image-${index}-${page.slug}`}
|
||||
className={`th-carousel-image-wrapper ${
|
||||
index === currentIndex ? "th-active" : "th-hidden"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={`${page.slug} image ${index + 1}`}
|
||||
className="th-carousel-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageCarousel.propTypes = {
|
||||
className: PropTypes.string,
|
||||
interval: PropTypes.number,
|
||||
page: PropTypes.shape({
|
||||
images: PropTypes.array,
|
||||
slug: PropTypes.any,
|
||||
}),
|
||||
};
|
||||
|
||||
export default ImageCarousel;
|
||||
67
src/components/ImageSidebar.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import PropTypes from "prop-types";
|
||||
import Image from "./Image";
|
||||
|
||||
const ImageSidebar = ({ imageCollections, currentSlug }) => {
|
||||
// Helper function to get the grid layout based on number of images
|
||||
const getGridLayout = (count) => {
|
||||
switch (count) {
|
||||
case 1:
|
||||
return "th-single-image-grid";
|
||||
case 2:
|
||||
return "th-two-images-grid";
|
||||
case 3:
|
||||
return "th-three-images-grid";
|
||||
case 4:
|
||||
return "th-four-images-grid";
|
||||
default:
|
||||
return "th-multiple-images-grid";
|
||||
}
|
||||
};
|
||||
|
||||
// Render a separate div for each page, controlling visibility with opacity
|
||||
return (
|
||||
<div className="th-images-sidebar">
|
||||
{imageCollections.map((page) => {
|
||||
const isCurrentPage = page.slug === currentSlug;
|
||||
const pageImages = page.images || [];
|
||||
const pageImageCount = pageImages.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`page-${page.slug}`}
|
||||
className={`th-images-container ${getGridLayout(pageImageCount)} ${
|
||||
isCurrentPage ? "" : "th-images-container-hidden"
|
||||
}`}
|
||||
>
|
||||
{pageImages.length > 0 ? (
|
||||
pageImages.map((image, index) => (
|
||||
<div
|
||||
key={`image-${index}-${page.slug}`}
|
||||
className="th-image-wrapper"
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt={`${page.slug} image ${index + 1}`}
|
||||
className="th-sidebar-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div key={`no-images-${page.slug}`} className="th-no-images">
|
||||
<p>No images available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageSidebar.propTypes = {
|
||||
currentSlug: PropTypes.string,
|
||||
imageCollections: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default ImageSidebar;
|
||||
38
src/components/LoadingModal.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const LoadingModal = ({ visible = true }) => {
|
||||
const [show, setShow] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout;
|
||||
if (visible) {
|
||||
// show immediately
|
||||
setShow(true);
|
||||
} else {
|
||||
// delay hiding by 150ms
|
||||
timeout = setTimeout(() => setShow(false), 150);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`th-loading-modal ${
|
||||
show ? "th-loading-modal-visible" : "th-loading-modal-hidden"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={"/assets/logo-dark.svg"}
|
||||
alt="The Hideout Logo"
|
||||
className="th-loading-logo"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LoadingModal.propTypes = {
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default LoadingModal;
|
||||
32
src/components/MenuButton.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { useMenu } from "../contexts/MenuContext";
|
||||
import { Cross as Hamburger } from "hamburger-react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const MenuButton = ({ isInverted = false }) => {
|
||||
const { setMenuVisible, menuVisible } = useMenu();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<button className="th-header-button th-header-button-menu">
|
||||
<Hamburger
|
||||
toggled={menuVisible}
|
||||
toggle={setMenuVisible}
|
||||
size={20}
|
||||
rounded
|
||||
color={
|
||||
isInverted
|
||||
? theme?.backgroundColor || "#ffffff"
|
||||
: theme?.textColor || "#ffffff"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
MenuButton.propTypes = {
|
||||
isInverted: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default MenuButton;
|
||||
211
src/components/Page.jsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Layout } from "antd";
|
||||
import ContentRenderer from "./ContentRenderer";
|
||||
import HeaderLogo from "./HeaderLogo";
|
||||
import ScrollIcon from "../icons/ScrollIcon";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import ImageCarousel from "./ImageCarousel";
|
||||
import CloseButton from "./CloseButton";
|
||||
import MenuButton from "./MenuButton";
|
||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const Page = ({
|
||||
pageData,
|
||||
theme = null,
|
||||
visible = true,
|
||||
id = 0,
|
||||
showClose = false,
|
||||
isSubPage = false,
|
||||
showMenu = true,
|
||||
onClose = () => {},
|
||||
}) => {
|
||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
const mobileImage =
|
||||
isMobile &&
|
||||
!pageData.showProperties &&
|
||||
!pageData.showContactForm &&
|
||||
!pageData.hideMobileImage;
|
||||
const contentRef = useRef(null);
|
||||
const [isImageShrunk, setIsImageShrunk] = useState(false);
|
||||
const [safariBlurToggle, setSafariBlurToggle] = useState(false);
|
||||
const shrinkDistance = 1;
|
||||
|
||||
const settings = useSettingsContext();
|
||||
const themes = settings?.themes || [];
|
||||
|
||||
if (theme == null) {
|
||||
theme = settings?.globalThemes?.page || settings?.themes[0];
|
||||
}
|
||||
|
||||
// Reset scroll position and image state when visible becomes false
|
||||
useEffect(() => {
|
||||
if (!visible && contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
setIsImageShrunk(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// SafariBlurToggle animation when isImageShrunk changes
|
||||
useEffect(() => {
|
||||
if (!mobileImage) return;
|
||||
|
||||
let animationId;
|
||||
let timeoutId;
|
||||
const startTime = Date.now();
|
||||
const duration = 750; // 0.75 seconds
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (elapsed < duration) {
|
||||
setSafariBlurToggle((prev) => !prev);
|
||||
animationId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Stop toggling after 0.75s
|
||||
setSafariBlurToggle(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the animation
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [isImageShrunk, mobileImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current || !mobileImage) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
// Don't shrink image if page is not visible
|
||||
if (!visible) return;
|
||||
|
||||
const el = contentRef.current;
|
||||
const scrollTop = el.scrollTop;
|
||||
const scrollHeight = el.scrollHeight;
|
||||
const clientHeight = el.clientHeight;
|
||||
|
||||
// Check if the container can be scrolled (is overflowing)
|
||||
const canScroll = scrollHeight > clientHeight;
|
||||
|
||||
const shouldShrink = scrollTop > shrinkDistance;
|
||||
|
||||
// If container can't be scrolled, keep image shrunk if it's already shrunk
|
||||
if (!canScroll && isImageShrunk) {
|
||||
return; // Keep the image shrunk
|
||||
}
|
||||
|
||||
// If container can be scrolled, allow image to un-shrink when at top
|
||||
if (canScroll) {
|
||||
if (scrollTop <= shrinkDistance) {
|
||||
setIsImageShrunk(false);
|
||||
} else if (shouldShrink) {
|
||||
setIsImageShrunk(true);
|
||||
}
|
||||
} else if (shouldShrink) {
|
||||
// If container can't be scrolled but we're past threshold, shrink
|
||||
setIsImageShrunk(true);
|
||||
}
|
||||
};
|
||||
|
||||
const el = contentRef.current;
|
||||
el.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [mobileImage, visible, isImageShrunk]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
!isMobile
|
||||
? "th-page-container"
|
||||
: "th-page-container th-page-container-mobile"
|
||||
}
|
||||
style={{
|
||||
"--th-backgroundColor": theme?.backgroundColor,
|
||||
"--th-textColor": theme?.textColor,
|
||||
background: "var(--th-backgroundColor)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`th-header${visible == false ? " th-header-hidden" : ""}${
|
||||
isLargeMobile ? " th-header-mobile" : ""
|
||||
}${mobileImage ? " th-header-float" : ""}`}
|
||||
style={{
|
||||
"--th-textColor":
|
||||
pageData?.invertHeader && isMobile
|
||||
? theme?.backgroundColor
|
||||
: theme?.textColor,
|
||||
}}
|
||||
>
|
||||
<HeaderLogo
|
||||
themes={themes}
|
||||
currentTheme={pageData.theme}
|
||||
large={id === 0 && !isSubPage}
|
||||
/>
|
||||
{showClose && <CloseButton onClick={onClose} />}
|
||||
{showMenu && id != 0 && !isSubPage && (
|
||||
<MenuButton isInverted={pageData.invertHeader && isMobile} />
|
||||
)}
|
||||
</div>
|
||||
{mobileImage && (
|
||||
<div
|
||||
className={`th-page-mobile-image-wrapper${
|
||||
visible == false ? ` th-page-mobile-image-wrapper-hidden` : ``
|
||||
}${isImageShrunk ? ` th-page-mobile-image-wrapper-shrunk` : ``}`}
|
||||
>
|
||||
<div
|
||||
className={`th-top-gradient ${safariBlurToggle ? "refresh" : ""}`}
|
||||
></div>
|
||||
<ImageCarousel page={pageData} className="th-mobile-image" />
|
||||
</div>
|
||||
)}
|
||||
<Content
|
||||
className={`th-page-content${
|
||||
visible == false ? " th-page-content-hidden" : ""
|
||||
}${isLargeMobile ? " th-page-content-mobile" : ""}`}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div className="th-content-container-wrapper">
|
||||
<div
|
||||
className={`th-content-container ${
|
||||
mobileImage ? " th-content-container-mobile-image" : ""
|
||||
}`}
|
||||
>
|
||||
<ContentRenderer content={pageData?.content} />
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
{pageData?.showScroll == true && <ScrollIcon visible={visible} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Page.propTypes = {
|
||||
id: PropTypes.number,
|
||||
pageData: PropTypes.object,
|
||||
theme: PropTypes.shape({
|
||||
backgroundColor: PropTypes.any,
|
||||
textColor: PropTypes.any,
|
||||
}),
|
||||
isSubPage: PropTypes.bool,
|
||||
showClose: PropTypes.bool,
|
||||
showMenu: PropTypes.bool,
|
||||
themes: PropTypes.any,
|
||||
visible: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Page;
|
||||
53
src/components/PropertyCard.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import PropTypes from "prop-types";
|
||||
import ImageCarousel from "./ImageCarousel";
|
||||
import FeaturesList from "./FeaturesList";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const PropertyCard = ({ propertyData, key }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
className="th-property-card-wrapper"
|
||||
onClick={() => {
|
||||
navigate(`/properties/${propertyData.slug}`);
|
||||
}}
|
||||
>
|
||||
<div className="th-property-card" key={key}>
|
||||
<div className="th-property-card-image-wrapper">
|
||||
{propertyData?.images ? (
|
||||
<ImageCarousel
|
||||
page={propertyData}
|
||||
className="th-property-card-image"
|
||||
/>
|
||||
) : (
|
||||
<div className="th-property-card-placeholder-image">No Image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="th-property-card-content">
|
||||
<h3 className="th-property-card-title">{propertyData?.name}</h3>
|
||||
<p className="th-property-card-address">
|
||||
{propertyData?.address.split("\n").join(", ")}
|
||||
</p>
|
||||
<FeaturesList features={propertyData?.features} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PropertyCard.propTypes = {
|
||||
key: PropTypes.any,
|
||||
propertyData: PropTypes.shape({
|
||||
address: PropTypes.shape({
|
||||
split: PropTypes.func,
|
||||
}),
|
||||
features: PropTypes.shape({
|
||||
map: PropTypes.func,
|
||||
}),
|
||||
images: PropTypes.any,
|
||||
name: PropTypes.any,
|
||||
slug: PropTypes.any,
|
||||
}),
|
||||
};
|
||||
|
||||
export default PropertyCard;
|
||||
100
src/components/PropertyCards.jsx
Normal file
@ -0,0 +1,100 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect } from "react";
|
||||
import PropertyCard from "./PropertyCard";
|
||||
import RightIcon from "../icons/RightIcon";
|
||||
import LeftIcon from "../icons/LeftIcon";
|
||||
|
||||
const PropertyCards = ({ properties }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const scrollToIndex = (index) => {
|
||||
const clampedIndex = Math.max(0, Math.min(properties.length - 1, index));
|
||||
setCurrentIndex(clampedIndex);
|
||||
|
||||
const container = document.getElementById("property-cards-container");
|
||||
const target = container?.children[clampedIndex];
|
||||
if (container && target) {
|
||||
container.scrollTo({
|
||||
left: target.offsetLeft,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollLeft = () => scrollToIndex(currentIndex - 1);
|
||||
const scrollRight = () => scrollToIndex(currentIndex + 1);
|
||||
|
||||
// Scroll spy to track visible card
|
||||
useEffect(() => {
|
||||
const container = document.getElementById("property-cards-container");
|
||||
if (!container) return;
|
||||
|
||||
let scrollTimeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
const containerCenter = container.scrollLeft + container.clientWidth / 2;
|
||||
|
||||
let closestIndex = 0;
|
||||
let minDistance = Infinity;
|
||||
|
||||
Array.from(container.children).forEach((el, i) => {
|
||||
const elCenter = el.offsetLeft + el.offsetWidth / 2;
|
||||
const distance = Math.abs(containerCenter - elCenter);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => setCurrentIndex(closestIndex), 50);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(scrollTimeout);
|
||||
};
|
||||
}, [properties]);
|
||||
|
||||
return (
|
||||
<div className="th-property-cards-container">
|
||||
{currentIndex != 0 && (
|
||||
<>
|
||||
<button className="th-scroll-btn th-left" onClick={scrollLeft}>
|
||||
<LeftIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`th-property-cards-gradient th-property-cards-gradient-left ${
|
||||
currentIndex == properties.length - 1 && properties.length > 1
|
||||
? ""
|
||||
: "th-hidden"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="th-property-cards" id="property-cards-container">
|
||||
{properties.map((property, i) => (
|
||||
<PropertyCard propertyData={property} key={i} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`th-property-cards-gradient th-property-cards-gradient-right ${
|
||||
!(currentIndex !== properties.length - 1) ? "th-hidden" : ""
|
||||
}`}
|
||||
/>
|
||||
{currentIndex !== properties.length - 1 && (
|
||||
<button className="th-scroll-btn th-right" onClick={scrollRight}>
|
||||
<RightIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PropertyCards.propTypes = {
|
||||
properties: PropTypes.array,
|
||||
};
|
||||
|
||||
export default PropertyCards;
|
||||
134
src/components/PropertyPage.jsx
Normal file
@ -0,0 +1,134 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Layout } from "antd";
|
||||
import ContentRenderer from "./ContentRenderer";
|
||||
import HeaderLogo from "./HeaderLogo";
|
||||
import FeaturesList from "./FeaturesList";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import ImageCarousel from "./ImageCarousel";
|
||||
import CloseButton from "./CloseButton";
|
||||
import AirbnbLogo from "../icons/AirbnbLogo";
|
||||
import BookingcomLogo from "../icons/BookingcomLogo";
|
||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const PropertyPage = ({ propertyData, visible = true, onClose = () => {} }) => {
|
||||
const settings = useSettingsContext();
|
||||
const themes = settings?.themes || [];
|
||||
const theme = settings?.globalThemes?.property || settings?.themes[0];
|
||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
console.log("Rerender " + propertyData.name, visible);
|
||||
const mobileImage = isMobile;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
!isMobile
|
||||
? "th-page-container th-property-page"
|
||||
: "th-page-container th-page-container-mobile th-property-page"
|
||||
}
|
||||
style={{
|
||||
"--th-backgroundColor": theme.backgroundColor,
|
||||
"--th-textColor": theme.textColor,
|
||||
background: "var(--th-backgroundColor)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`th-header${visible == false ? " th-header-hidden" : ""}${
|
||||
isLargeMobile ? " th-header-mobile" : ""
|
||||
}${mobileImage ? " th-header-float" : ""}`}
|
||||
>
|
||||
<HeaderLogo themes={themes} currentTheme={theme.name} />
|
||||
<CloseButton onClick={onClose} />
|
||||
</div>
|
||||
{mobileImage && (
|
||||
<div className="th-page-mobile-image-wrapper">
|
||||
<ImageCarousel page={propertyData} className="th-mobile-image" />
|
||||
</div>
|
||||
)}
|
||||
<Content
|
||||
className={`th-page-content${
|
||||
visible == false ? " th-page-content-hidden" : ""
|
||||
}${isLargeMobile ? " th-page-content-mobile" : ""}`}
|
||||
style={{ color: "var(--th-textColor)" }}
|
||||
>
|
||||
<div className="th-content-container-wrapper">
|
||||
<div className="th-content-container">
|
||||
<h1 className="th-title">
|
||||
{propertyData?.name ? propertyData.name.toLowerCase() : "n/a"}
|
||||
</h1>
|
||||
|
||||
<hr className="th-divider" />
|
||||
<p className="th-paragraph th-property-address">
|
||||
{propertyData?.address.split("\n").join(", ").toLowerCase()}
|
||||
</p>
|
||||
<div className="th-property-specs">
|
||||
<p>
|
||||
price:{" "}
|
||||
<span>
|
||||
£{propertyData.minPrice} - £{propertyData.maxPrice}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
max capacity: <span>{propertyData.maxOccupancy}</span>
|
||||
</p>
|
||||
<p>
|
||||
bathrooms: <span>{propertyData.bathrooms}</span>
|
||||
</p>
|
||||
<p>
|
||||
bedrooms: <span>{propertyData.bedrooms}</span>
|
||||
</p>
|
||||
</div>
|
||||
<FeaturesList features={propertyData?.features} maxVisible={null} />
|
||||
{propertyData?.content?.length > 0 && <hr className="th-divider" />}
|
||||
{mobileImage && (
|
||||
<div className="th-mobile-image-wrapper">
|
||||
<ImageCarousel
|
||||
page={propertyData}
|
||||
className="th-mobile-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ContentRenderer content={propertyData?.content} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? "th-mobile-booking-buttons th-booking-buttons"
|
||||
: "th-booking-buttons"
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="th-airbnb-button"
|
||||
onClick={() => window.open(propertyData?.airbnbLink, "_blank")}
|
||||
>
|
||||
<AirbnbLogo />
|
||||
Airbnb
|
||||
</button>
|
||||
<button
|
||||
className="th-bookingcom-button"
|
||||
onClick={() => window.open(propertyData?.bookingcomLink, "_blank")}
|
||||
>
|
||||
<BookingcomLogo />
|
||||
Booking.com
|
||||
</button>
|
||||
</div>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PropertyPage.propTypes = {
|
||||
propertyData: PropTypes.object.isRequired,
|
||||
theme: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
backgroundColor: PropTypes.string,
|
||||
textColor: PropTypes.string,
|
||||
}),
|
||||
themes: PropTypes.any,
|
||||
visible: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default PropertyPage;
|
||||
45
src/components/SubPage.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
|
||||
export default function SubPage({ visible, onClose, children }) {
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
className={
|
||||
isMobile ? "th-mobile-sub-page-wrapper" : "th-sub-page-wrapper"
|
||||
}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Full height panel */}
|
||||
<motion.div
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: "-100%" }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
SubPage.propTypes = {
|
||||
children: PropTypes.any,
|
||||
onClose: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
66
src/components/Turnstile.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Turnstile = forwardRef(
|
||||
({ siteKey = null, onVerify, theme = "auto", size = "normal" }, ref) => {
|
||||
const widgetRef = useRef(null);
|
||||
const widgetIdRef = useRef(null);
|
||||
|
||||
// Expose reset function to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if (window.turnstile && widgetIdRef.current !== null) {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetRef.current || widgetIdRef.current !== null) return;
|
||||
|
||||
const renderWidget = () => {
|
||||
if (!window.turnstile) return;
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(widgetRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token) => {
|
||||
if (typeof onVerify === "function") {
|
||||
onVerify(token);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!document.getElementById("cf-turnstile-script")) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||
script.id = "cf-turnstile-script";
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
script.onload = renderWidget;
|
||||
} else {
|
||||
renderWidget();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.turnstile && widgetIdRef.current !== null) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
return <div ref={widgetRef} data-theme={theme} data-size={size}></div>;
|
||||
},
|
||||
);
|
||||
|
||||
Turnstile.displayName = "Turnstile";
|
||||
|
||||
Turnstile.propTypes = {
|
||||
siteKey: PropTypes.string.isRequired,
|
||||
onVerify: PropTypes.func,
|
||||
theme: PropTypes.string,
|
||||
size: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Turnstile;
|
||||
35
src/components/TurnstileModal.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "antd";
|
||||
import Turnstile from "react-turnstile";
|
||||
|
||||
const TurnstileModal = ({ open, onClose, onSuccess }) => {
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
const handleVerify = (token) => {
|
||||
setTurnstileToken(token);
|
||||
onClose(); // Close modal after verification
|
||||
onSuccess(turnstileToken); // Notify parent component
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
closeIcon={null}
|
||||
centered
|
||||
className="tbturnstile"
|
||||
>
|
||||
<Turnstile sitekey="0x4AAAAAAA_bc3QTrE68whtg" onVerify={handleVerify} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
TurnstileModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default TurnstileModal;
|
||||
29
src/contexts/ActionContext.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext, useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
const ActionContext = createContext(null);
|
||||
|
||||
export const ActionProvider = ({ onAction, children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const action = searchParams.get("action");
|
||||
if (action && typeof onAction?.[action] === "function") {
|
||||
// collect all params except action
|
||||
|
||||
const params = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== "action") {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
onAction[action](params);
|
||||
}
|
||||
}, [location, onAction]);
|
||||
|
||||
return <ActionContext.Provider value={{}}>{children}</ActionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useActionContext = () => useContext(ActionContext);
|
||||
334
src/contexts/ImageContext.jsx
Normal file
@ -0,0 +1,334 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
// Image object: { blurHash: string, url: string, mirrorUrl?: string }
|
||||
const ImageContext = createContext();
|
||||
|
||||
export const useImageContext = () => useContext(ImageContext);
|
||||
|
||||
class ImageCache {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.maxCacheSize = 50;
|
||||
this.cacheOrder = [];
|
||||
this.loadingPromises = new Map(); // Track ongoing loading operations
|
||||
}
|
||||
getCacheKey(url) {
|
||||
return url;
|
||||
}
|
||||
isCached(url) {
|
||||
return this.cache.has(this.getCacheKey(url));
|
||||
}
|
||||
get(url) {
|
||||
const key = this.getCacheKey(url);
|
||||
if (this.cache.has(key)) {
|
||||
const index = this.cacheOrder.indexOf(key);
|
||||
if (index > -1) this.cacheOrder.splice(index, 1);
|
||||
this.cacheOrder.push(key);
|
||||
return this.cache.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
set(url, blobUrl) {
|
||||
const key = this.getCacheKey(url);
|
||||
if (this.cache.has(key)) {
|
||||
const index = this.cacheOrder.indexOf(key);
|
||||
if (index > -1) this.cacheOrder.splice(index, 1);
|
||||
}
|
||||
this.cache.set(key, blobUrl);
|
||||
this.cacheOrder.push(key);
|
||||
this.evictIfNeeded();
|
||||
}
|
||||
evictIfNeeded() {
|
||||
while (this.cache.size > this.maxCacheSize) {
|
||||
const oldestKey = this.cacheOrder.shift();
|
||||
if (oldestKey && this.cache.has(oldestKey)) {
|
||||
const blobUrl = this.cache.get(oldestKey);
|
||||
if (blobUrl && blobUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
async loadImage(primaryUrl, fallbackUrl = null) {
|
||||
// Check cache using primary URL as key
|
||||
const cached = this.get(primaryUrl);
|
||||
if (cached) {
|
||||
console.log(`[ImageCache] Using cached image: ${primaryUrl}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if this image is already being loaded
|
||||
const key = this.getCacheKey(primaryUrl);
|
||||
if (this.loadingPromises.has(key)) {
|
||||
console.log(
|
||||
`[ImageCache] Image already loading, waiting for existing promise: ${primaryUrl}`,
|
||||
);
|
||||
return this.loadingPromises.get(key);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ImageCache] Starting to load image: ${primaryUrl}${fallbackUrl ? ` with fallback: ${fallbackUrl}` : ""}`,
|
||||
);
|
||||
// Create a new loading promise
|
||||
const loadingPromise = this._performImageLoadWithFallback(
|
||||
primaryUrl,
|
||||
fallbackUrl,
|
||||
);
|
||||
this.loadingPromises.set(key, loadingPromise);
|
||||
|
||||
try {
|
||||
const result = await loadingPromise;
|
||||
console.log(`[ImageCache] Successfully loaded image: ${primaryUrl}`);
|
||||
return result;
|
||||
} finally {
|
||||
// Clean up the loading promise when done
|
||||
this.loadingPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async _performImageLoadWithFallback(primaryUrl, fallbackUrl) {
|
||||
// Try mirror URL first if available
|
||||
if (fallbackUrl) {
|
||||
try {
|
||||
console.log(`[ImageCache] Trying mirror URL first: ${fallbackUrl}`);
|
||||
const response = await fetch(fallbackUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mirror URL HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this.set(primaryUrl, blobUrl); // Cache using primary URL as key
|
||||
console.log(
|
||||
`[ImageCache] Successfully loaded from mirror URL: ${fallbackUrl}`,
|
||||
);
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.warn(`[ImageCache] Mirror URL failed: ${fallbackUrl}`, error);
|
||||
console.log(`[ImageCache] Falling back to primary URL: ${primaryUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to primary URL
|
||||
try {
|
||||
const response = await fetch(primaryUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Primary URL HTTP error! status: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this.set(primaryUrl, blobUrl);
|
||||
console.log(
|
||||
`[ImageCache] Successfully loaded from primary URL: ${primaryUrl}`,
|
||||
);
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.error(`[ImageCache] Both URLs failed for: ${primaryUrl}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _performImageLoad(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok)
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load image: ${url}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async preloadImages(images) {
|
||||
// images: [{ url, mirrorUrl?, ... }]
|
||||
const results = new Map();
|
||||
const promises = images.map(async (image) => {
|
||||
try {
|
||||
const blobUrl = await this.loadImage(image.url, image.mirrorUrl);
|
||||
results.set(image.url, blobUrl);
|
||||
} catch (error) {
|
||||
results.set(image.url, null);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
return results;
|
||||
}
|
||||
clear() {
|
||||
for (const blobUrl of this.cache.values()) {
|
||||
if (blobUrl && blobUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
this.cache.clear();
|
||||
this.cacheOrder = [];
|
||||
this.loadingPromises.clear();
|
||||
}
|
||||
remove(url) {
|
||||
const key = this.getCacheKey(url);
|
||||
if (this.cache.has(key)) {
|
||||
const blobUrl = this.cache.get(key);
|
||||
if (blobUrl && blobUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
this.cache.delete(key);
|
||||
const index = this.cacheOrder.indexOf(key);
|
||||
if (index > -1) this.cacheOrder.splice(index, 1);
|
||||
}
|
||||
// Also clean up any ongoing loading promise
|
||||
this.loadingPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const imageCache = new ImageCache();
|
||||
|
||||
export const ImageProvider = ({ children }) => {
|
||||
const [imageObjects, setImageObjects] = useState([]);
|
||||
const [allImagesLoaded, setAllImagesLoaded] = useState(false);
|
||||
|
||||
const loadImages = useCallback(async (images) => {
|
||||
// images: [{ blurHash, url, mirrorUrl? }]
|
||||
if (!images || images.length === 0) {
|
||||
setImageObjects([]);
|
||||
setAllImagesLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAllImagesLoaded(false);
|
||||
|
||||
// Initialize image objects with loading state
|
||||
const initialImageObjects = images.map((img) => ({
|
||||
src: img.url,
|
||||
blurHash: img.blurHash,
|
||||
mirrorUrl: img.mirrorUrl,
|
||||
blob: null,
|
||||
loadingState: "loading",
|
||||
}));
|
||||
setImageObjects(initialImageObjects);
|
||||
|
||||
// Load images individually and update state as each completes
|
||||
const loadPromises = images.map(async (img) => {
|
||||
try {
|
||||
const blobUrl = await imageCache.loadImage(img.url, img.mirrorUrl);
|
||||
console.log(`Image loaded: ${img.url}`);
|
||||
// Update the specific image object as it loads
|
||||
setImageObjects((prev) =>
|
||||
prev.map((imgObj) =>
|
||||
imgObj.src === img.url
|
||||
? { ...imgObj, blob: blobUrl, loadingState: "loaded" }
|
||||
: imgObj,
|
||||
),
|
||||
);
|
||||
return { url: img.url, blobUrl, success: true };
|
||||
} catch (error) {
|
||||
console.error(`Failed to load image: ${img.url}`, error);
|
||||
// Update the specific image object with error state
|
||||
setImageObjects((prev) =>
|
||||
prev.map((imgObj) =>
|
||||
imgObj.src === img.url
|
||||
? { ...imgObj, loadingState: "error" }
|
||||
: imgObj,
|
||||
),
|
||||
);
|
||||
return { url: img.url, blobUrl: null, success: false, error };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all images to complete (but state updates happen individually)
|
||||
await Promise.allSettled(loadPromises);
|
||||
console.log("All images loading completed");
|
||||
setAllImagesLoaded(true);
|
||||
}, []);
|
||||
|
||||
const loadIndividualImage = useCallback(
|
||||
async (url) => {
|
||||
console.log(`[ImageProvider] loadIndividualImage called for: ${url}`);
|
||||
const imageObj = imageObjects.find((img) => img.src === url);
|
||||
|
||||
// Check if already loaded or currently loading
|
||||
if (
|
||||
imageObj?.loadingState === "loaded" ||
|
||||
imageObj?.loadingState === "loading"
|
||||
) {
|
||||
console.log(
|
||||
`[ImageProvider] Image already ${imageObj.loadingState}: ${url}`,
|
||||
);
|
||||
return; // Already loading or loaded
|
||||
}
|
||||
|
||||
// Check if image is already cached
|
||||
if (imageCache.isCached(url)) {
|
||||
console.log(
|
||||
`[ImageProvider] Image found in cache, updating state: ${url}`,
|
||||
);
|
||||
const blobUrl = imageCache.get(url);
|
||||
setImageObjects((prev) =>
|
||||
prev.map((img) =>
|
||||
img.src === url
|
||||
? { ...img, blob: blobUrl, loadingState: "loaded" }
|
||||
: img,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ImageProvider] Starting to load individual image: ${url}`);
|
||||
// Update loading state
|
||||
setImageObjects((prev) =>
|
||||
prev.map((img) =>
|
||||
img.src === url ? { ...img, loadingState: "loading" } : img,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const mirrorUrl = imageObj?.mirrorUrl;
|
||||
const blobUrl = await imageCache.loadImage(url, mirrorUrl);
|
||||
setImageObjects((prev) =>
|
||||
prev.map((img) =>
|
||||
img.src === url
|
||||
? { ...img, blob: blobUrl, loadingState: "loaded" }
|
||||
: img,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setImageObjects((prev) =>
|
||||
prev.map((img) =>
|
||||
img.src === url ? { ...img, loadingState: "error" } : img,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[imageObjects],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
setImageObjects([]);
|
||||
setAllImagesLoaded(false);
|
||||
imageCache.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ImageContext.Provider
|
||||
value={{
|
||||
imageObjects, // [{ src, blurHash, mirrorUrl, blob, loadingState }]
|
||||
allImagesLoaded,
|
||||
loadIndividualImage,
|
||||
loadImages, // function to load images
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ImageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageContext;
|
||||
75
src/contexts/MenuContext.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import MenuButton from "../components/MenuButton";
|
||||
// Create the context
|
||||
const MenuContext = createContext();
|
||||
|
||||
export const useMenu = () => useContext(MenuContext);
|
||||
|
||||
export const MenuProvider = ({ children, pages, currentPageSlug }) => {
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||
const navigate = useNavigate();
|
||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={{ menuVisible, setMenuVisible }}>
|
||||
{children}
|
||||
|
||||
{/* Fullscreen menu overlay */}
|
||||
<AnimatePresence>
|
||||
{menuVisible && (
|
||||
<motion.div
|
||||
className={`th-menu-page${isMobile ? " th-mobile-menu-page" : ""}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
className={`th-header${isLargeMobile ? " th-header-mobile" : ""} th-menu-header`}
|
||||
>
|
||||
<MenuButton />
|
||||
</div>
|
||||
<div
|
||||
className={`th-menu-page-container${
|
||||
isMobile ? " th-mobile-menu-page-container" : ""
|
||||
}`}
|
||||
>
|
||||
<ul className="th-menu-nav">
|
||||
{pages
|
||||
.filter((page) => page.pageType === "landingPage")
|
||||
.map((page) => (
|
||||
<li key={page.slug}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(page.slug);
|
||||
setMenuVisible(false);
|
||||
}}
|
||||
className={`th-menu-nav-item${page.slug === currentPageSlug ? " th-menu-nav-item-active" : ""}`}
|
||||
>
|
||||
{page.name || page.slug}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</MenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
MenuProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
currentPageSlug: PropTypes.string.isRequired,
|
||||
pages: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
slug: PropTypes.string.isRequired,
|
||||
label: PropTypes.string, // optional display label
|
||||
}),
|
||||
).isRequired,
|
||||
};
|
||||
32
src/contexts/SettingsContext.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const SettingsContext = createContext(null);
|
||||
|
||||
export const SettingsProvider = ({ settings: initialSettings, children }) => {
|
||||
const [settings, setSettings] = useState(initialSettings);
|
||||
|
||||
// Update internal state whenever the prop changes
|
||||
useEffect(() => {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings]);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsProvider.propTypes = {
|
||||
settings: PropTypes.object.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export const useSettingsContext = () => {
|
||||
const settings = useContext(SettingsContext);
|
||||
if (!settings) {
|
||||
throw new Error("useSettingsContext must be used inside SettingsProvider");
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
53
src/contexts/ThemeContext.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
// Create context
|
||||
const ThemeContext = createContext({
|
||||
theme: null,
|
||||
});
|
||||
|
||||
// Provider
|
||||
export const ThemeProvider = ({ children, currentTheme }) => {
|
||||
const [theme, setTheme] = useState(currentTheme);
|
||||
|
||||
// Update theme whenever currentTheme changes
|
||||
useEffect(() => {
|
||||
if (currentTheme) {
|
||||
setTheme(currentTheme);
|
||||
}
|
||||
}, [currentTheme]);
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
document.body.classList.add("th-body-bg");
|
||||
document.body.style.backgroundColor = theme.backgroundColor || "";
|
||||
|
||||
// Set global CSS vars
|
||||
document.documentElement.style.setProperty(
|
||||
"--th-textColor",
|
||||
theme.textColor || "#000000",
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--th-backgroundColor",
|
||||
theme.backgroundColor || "#ffffff",
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove("th-body-bg");
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme }}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeProvider.propTypes = {
|
||||
children: PropTypes.any,
|
||||
currentTheme: PropTypes.object.isRequired, // The theme object
|
||||
};
|
||||
|
||||
// Hook for consuming
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
7
src/icons/AirbnbLogo.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import AirbnbLogoSvg from "../../assets/airbnblogo.svg?react";
|
||||
|
||||
const AirbnbLogo = () => {
|
||||
return <AirbnbLogoSvg />;
|
||||
};
|
||||
|
||||
export default AirbnbLogo;
|
||||
7
src/icons/BookingcomLogo.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import BookingcomLogoSvg from "../../assets/bookingcomlogo.svg?react";
|
||||
|
||||
const BookingcomLogo = () => {
|
||||
return <BookingcomLogoSvg />;
|
||||
};
|
||||
|
||||
export default BookingcomLogo;
|
||||
7
src/icons/CheckIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import CheckIconSvg from "../../assets/checkicon.svg?react";
|
||||
|
||||
const CheckIcon = () => {
|
||||
return <CheckIconSvg />;
|
||||
};
|
||||
|
||||
export default CheckIcon;
|
||||
7
src/icons/CloseIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import CloseIconSvg from "../../assets/closeicon.svg?react";
|
||||
|
||||
const CloseIcon = () => {
|
||||
return <CloseIconSvg />;
|
||||
};
|
||||
|
||||
export default CloseIcon;
|
||||
7
src/icons/LeftIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import LeftIconSvg from "../../assets/lefticon.svg?react";
|
||||
|
||||
const LeftIcon = () => {
|
||||
return <LeftIconSvg />;
|
||||
};
|
||||
|
||||
export default LeftIcon;
|
||||
7
src/icons/LoadingIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import LoadingIconSvg from "../../assets/loadingicon.svg?react";
|
||||
|
||||
const LoadingIcon = () => {
|
||||
return <LoadingIconSvg />;
|
||||
};
|
||||
|
||||
export default LoadingIcon;
|
||||
7
src/icons/MenuIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import MenuIconSvg from "../../assets/menuicon.svg?react";
|
||||
|
||||
const MenuIcon = () => {
|
||||
return <MenuIconSvg />;
|
||||
};
|
||||
|
||||
export default MenuIcon;
|
||||
7
src/icons/RightIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import RightIconSvg from "../../assets/righticon.svg?react";
|
||||
|
||||
const RightIcon = () => {
|
||||
return <RightIconSvg />;
|
||||
};
|
||||
|
||||
export default RightIcon;
|
||||
23
src/icons/ScrollIcon.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import ScrollIconSvg from "../../assets/scrollicon.svg?react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ScrollIcon = ({ visible = true }) => {
|
||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||
return (
|
||||
<div
|
||||
className={`th-scroll-icon-wrapper ${
|
||||
isLargeMobile ? "th-scroll-icon-wrapper-mobile" : ""
|
||||
} ${visible == false ? "th-hidden" : ""}`}
|
||||
>
|
||||
<ScrollIconSvg />
|
||||
<p className="th-scroll-icon-text">SCROLL</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ScrollIcon.propTypes = {
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ScrollIcon;
|
||||
13
src/main.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "antd/dist/reset.css"; // Import Ant Design styles
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
64
src/utils/CacheReloadView.jsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { Card, Flex, message } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
|
||||
const CacheReloadView = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 600 });
|
||||
|
||||
// Fetch blogs from API
|
||||
useEffect(() => {
|
||||
const reloadCache = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://web.tombutcher.work/api/utils/cache",
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
window.close();
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Error reloading cache:", err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
message.error("Failed to reload cache!");
|
||||
}
|
||||
};
|
||||
|
||||
reloadCache();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="tbview">
|
||||
<Card
|
||||
className={`tbcard ${isMobile ? "tbcard-mobile" : "tbcard-desktop"}`}
|
||||
title="Reload Cache"
|
||||
>
|
||||
{loading ? (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
className="tb-loading-container"
|
||||
>
|
||||
<LoadingOutlined className="tb-loading-icon" spin />
|
||||
</Flex>
|
||||
) : error ? (
|
||||
<Flex justify="center" align="center">
|
||||
<p>Error to reload cache: {error}</p>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex justify="center" align="center">
|
||||
<p>Cache Reloaded!</p>
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheReloadView;
|
||||
14
vite.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import svgo from "vite-plugin-svgo";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgo(), svgr()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: ["thehideout.tombutcher.work"],
|
||||
},
|
||||
base: "/",
|
||||
});
|
||||
4
wrangler.jsonc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "thehideout-ui",
|
||||
"pages_build_output_dir": "./dist",
|
||||
}
|
||||