Compare commits
3 Commits
cd7b1bf9f3
...
cb68b2da97
| Author | SHA1 | Date |
|---|---|---|
|
|
cb68b2da97 | |
|
|
3660290f1b | |
|
|
8d7d0a74a6 |
79
index.css
79
index.css
|
|
@ -4023,11 +4023,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-logo {
|
.url-input-logo {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-logo img {
|
.url-input-logo img {
|
||||||
height: 48px;
|
height: 70px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4113,6 +4113,27 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.url-input-manual-button {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(155, 202, 204, 0.35);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(155, 202, 204, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-manual-button:hover {
|
||||||
|
border-color: #9BCACC;
|
||||||
|
color: #9BCACC;
|
||||||
|
background: rgba(155, 202, 204, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.url-input-error {
|
.url-input-error {
|
||||||
color: #F56565;
|
color: #F56565;
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
|
@ -4676,6 +4697,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-manual-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-manual-button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-button:hover {
|
.hero-button:hover {
|
||||||
background-color: #9a5ef0;
|
background-color: #9a5ef0;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
@ -9265,6 +9306,15 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.city-modal-item-all {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-modal-item-all.active {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
.ado2-contents-grid {
|
.ado2-contents-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
|
@ -12464,6 +12514,31 @@
|
||||||
border-color: #9BCACC;
|
border-color: #9BCACC;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-modal-city-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(155, 202, 204, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-modal-city-btn.selected {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-modal-city-btn:hover {
|
||||||
|
border-color: #9BCACC;
|
||||||
|
}
|
||||||
|
|
||||||
.manual-modal-actions {
|
.manual-modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 660.69 145.69">
|
||||||
|
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M53.46,93.51l11.28-12.51h64.08l-31.22-63.81L32.69,93.51H15.09L90.17,5.91c2.48-2.89,6.19-5.64,10.73-5.64s7.29,2.48,8.8,5.64l43.45,87.6H53.46Z"/>
|
||||||
|
<path class="st0" d="M162.65,93.51l10.04-57.34h14.99l-7.84,44.83h42.22c23.38,0,43.18-16.78,43.18-38.5,0-16.64-12.51-28.33-31.35-28.33h-57.21l11.83-12.65h47.58c26.68,0,44.14,15.68,44.14,38.5,0,29.84-27.5,53.49-60.37,53.49h-57.21Z"/>
|
||||||
|
<path class="st0" d="M343.75,95.02c-36.17,0-54.45-12.1-54.45-38.37,0-38.09,23.24-56.65,71.23-56.65,36.17,0,54.46,11.96,54.46,38.23,0,38.09-23.1,56.79-71.23,56.79ZM358.33,12.51c-39.6,0-53.9,12.24-53.9,42.63,0,19.94,13.2,27.36,41.53,27.36,39.47,0,53.91-12.38,53.91-42.77,0-19.94-13.2-27.23-41.53-27.23Z"/>
|
||||||
|
<path class="st0" d="M417.47,93.51l4.81-27.23c2.89-16.5,15.68-24.48,36.58-24.48h40.15c16.91,0,23.79-5.78,23.79-15.68,0-9.08-9.08-11.96-26.4-11.96h-59.54l11.96-12.65h55.97c23.51,0,32.73,8.66,32.73,21.59,0,16.91-11,29.7-39.05,29.7h-40.15c-12.65,0-19.8,3.99-21.59,13.34l-2.47,14.85h94.75l-11.83,12.51h-99.7Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M542.35,92.89c-2.38,0-4.15-1.38-4.15-3.61,0-3.07,2.69-4.84,5.68-4.84,2.46,0,4.3,1.31,4.3,3.53,0,3.07-2.69,4.92-5.84,4.92Z"/>
|
||||||
|
<path class="st0" d="M577.37,92.2l6.3-6.99h35.79l-17.43-35.63-36.25,42.62h-9.83l41.93-48.92c1.38-1.61,3.46-3.15,5.99-3.15s4.07,1.38,4.92,3.15l24.27,48.92h-55.68Z"/>
|
||||||
|
<path class="st0" d="M643.26,92.2l9.14-51.38h8.29l-9.14,51.38h-8.29Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M.43,125.73c1.34-5.83,5.61-10.49,11.96-10.49,5.4,0,8.98,3.3,9.1,8.33,0,.17-.08.25-.23.25h-2.56c-.15,0-.23-.08-.23-.23-.17-3.74-2.48-5.9-6.18-5.9-4.69,0-7.56,3.67-8.65,8.31-1.31,5.71.33,11.09,6.32,11.09,3.43,0,6.17-1.81,7.52-6.23.03-.13.13-.2.27-.2h2.61c.17,0,.25.12.2.3-1.66,5.75-5.58,8.58-10.76,8.58-8.23,0-10.96-6.85-9.37-13.81Z"/>
|
||||||
|
<path class="st0" d="M25.26,129.28c1.02-4.34,4.26-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM38.31,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
||||||
|
<path class="st0" d="M44.55,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
<path class="st0" d="M66.61,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
||||||
|
<path class="st0" d="M78.34,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM91.3,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
|
||||||
|
<path class="st0" d="M97.15,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
<path class="st0" d="M119.21,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
||||||
|
<path class="st0" d="M138.69,138.85l2.7-11.23,2.63-11.75c.03-.15.13-.22.27-.22h3.48c.15,0,.23.08.25.22l2.25,14.38.77,4.94,3.05-5.01,8.73-14.36c.07-.12.17-.17.3-.17h3.84c.15,0,.25.1.2.27l-2.76,11.69-2.53,11.28c-.03.15-.13.22-.27.22h-2.66c-.17,0-.25-.1-.22-.27l2.13-9.05,2.4-9.94-11.64,19.08c-.07.12-.17.17-.29.17h-2.11c-.15,0-.23-.08-.27-.22l-3.03-19.33-2.23,10.24-2.08,9.1c-.03.15-.13.22-.27.22h-2.41c-.17,0-.25-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M168.95,129.55c1.01-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM181.38,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
||||||
|
<path class="st0" d="M187.85,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.28.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M202.32,138.87l2.71-11.38,2.58-11.61c.03-.15.13-.22.27-.22h2.58c.17,0,.27.1.22.27l-3.08,12.9,7.81-6.85c.1-.07.18-.12.3-.12h3.32c.22,0,.28.15.12.3l-7.04,5.93,3.94,10.74c.05.18-.02.29-.2.29h-2.68c-.13,0-.22-.05-.27-.2l-.8-2.51-2.33-6.4-2.98,2.61-1.39,6.3c-.03.15-.13.22-.27.22h-2.58c-.17,0-.25-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M219.82,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM232.79,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
|
||||||
|
<path class="st0" d="M240.64,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
||||||
|
<path class="st0" d="M251.75,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM256.2,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
|
||||||
|
<path class="st0" d="M260.47,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
<path class="st0" d="M279.79,140.28c0-.15.1-.25.25-.25h2.58c.15,0,.23.08.25.25.03,2.09,1.71,3.28,4.04,3.28,3.1,0,4.71-1.78,5.63-5.71l.28-1.26c-1.34,1.56-3.12,2.58-5.31,2.58-5.06,0-6.85-4.72-5.7-9.68.99-4.31,3.89-7.89,8.34-7.89,2.88,0,4.54,1.44,5.26,3.54l.65-3.05c.03-.15.13-.22.27-.22h2.58c.17,0,.25.1.22.27l-2.04,8.63-1.61,7.12c-1.21,5.26-4,7.81-8.73,7.81-4.04,0-6.97-2.14-6.97-5.41ZM294.28,130.83c.82-3.6.13-7.12-3.59-7.12-3.23,0-5.08,2.7-5.83,5.93-.87,3.77-.02,7.42,3.54,7.42,3.02,0,5.09-2.78,5.88-6.23Z"/>
|
||||||
|
<path class="st0" d="M307.42,138.82l13.57-22.99c.07-.12.15-.17.28-.17h3.69c.15,0,.23.07.25.23l2.85,22.95c.02.17-.08.27-.23.27h-2.56c-.15,0-.23-.08-.25-.23l-.79-6.85h-9.53l-3.95,6.92c-.07.12-.15.17-.29.17h-2.88c-.18,0-.25-.12-.15-.3ZM323.99,129.52l-1.31-11.61-6.62,11.61h7.92Z"/>
|
||||||
|
<path class="st0" d="M332.8,132.49l.74-3.25,1.61-7.15c.03-.15.13-.22.27-.22h2.63c.17,0,.25.1.2.27l-1.64,6.82-.72,3.3c-.75,3.3.49,4.94,2.78,4.94,2.63,0,4.49-2.43,5.63-5.19l.72-3.08,1.51-6.84c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-1.84,7.57-2.08,9.2c-.02.15-.12.22-.25.22h-2.51c-.15,0-.25-.1-.2-.27l.69-2.76c-1.24,1.86-3.43,3.43-6.01,3.43-3.38,0-5.71-2.48-4.66-7.04Z"/>
|
||||||
|
<path class="st0" d="M353.75,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
||||||
|
<path class="st0" d="M365.46,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM378.51,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
||||||
|
<path class="st0" d="M384.76,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.24,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
<path class="st0" d="M415.88,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM428.31,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
||||||
|
<path class="st0" d="M437.29,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
||||||
|
<path class="st0" d="M448.39,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM452.85,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
|
||||||
|
<path class="st0" d="M458.71,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM471.76,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
||||||
|
<path class="st0" d="M478,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
<path class="st0" d="M506.33,138.85l2.81-11.76,2.5-11.21c.03-.15.13-.22.27-.22h7.04c5.68,0,8.44,3.43,7.37,8.13-.9,3.89-4.31,6.28-9.6,6.28h-5.23l-1.98,8.83c-.03.15-.13.22-.27.22h-2.7c-.17,0-.25-.1-.22-.27ZM516.95,127.74c3.6,0,5.7-1.62,6.3-4.22.7-3.08-.79-5.51-4.52-5.51h-4.41l-2.33,9.73h4.96Z"/>
|
||||||
|
<path class="st0" d="M527.7,138.85l2.78-11.61,2.53-11.36c.03-.15.13-.22.27-.22h2.56c.17,0,.25.1.22.27l-2.7,11.28-2.61,11.69c-.03.15-.13.22-.27.22h-2.56c-.17,0-.27-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M537.92,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM550.35,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
||||||
|
<path class="st0" d="M559.32,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
||||||
|
<path class="st0" d="M572.12,138.85l1.83-7.52,1.71-7.22h-2.45c-.17,0-.25-.1-.22-.29l.42-1.74c.03-.15.13-.22.27-.22h2.5l.37-1.56c.85-3.77,3.32-5.09,5.71-5.09.8,0,1.64.12,2.23.25.15.05.22.15.18.3l-.4,1.69c-.03.17-.13.22-.3.17-.45-.12-.97-.17-1.57-.17-1.36,0-2.4.85-2.9,3.08l-.32,1.32h3.79c.17,0,.25.1.22.27l-.4,1.76c-.03.13-.13.22-.27.22h-3.85l-1.66,7.22-1.64,7.57c-.03.15-.13.22-.27.22h-2.75c-.17,0-.25-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M584.65,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM597.7,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
||||||
|
<path class="st0" d="M603.95,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.29.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
|
||||||
|
<path class="st0" d="M618.42,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.25,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
26
src/App.tsx
26
src/App.tsx
|
|
@ -14,8 +14,7 @@ import SocialConnectError from './pages/Social/SocialConnectError';
|
||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
import VideoDetailPage from './components/VideoDetailPage';
|
||||||
import LoginPromptModal from './components/LoginPromptModal';
|
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
|
|
||||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||||
import { CrawlingResponse } from './types/api';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
|
|
@ -101,8 +100,26 @@ const App: React.FC = () => {
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||||
|
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||||
|
const tutorialVideoCheckedRef = useRef(false);
|
||||||
|
|
||||||
|
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'generation_flow') return;
|
||||||
|
if (tutorialVideoCheckedRef.current) return;
|
||||||
|
tutorialVideoCheckedRef.current = true;
|
||||||
|
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||||
|
if (localStorage.getItem(ENABLED_KEY) !== null) return;
|
||||||
|
getVideosList(1, 1).then(response => {
|
||||||
|
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
|
||||||
|
if (hasVideos) {
|
||||||
|
localStorage.setItem(ENABLED_KEY, 'false');
|
||||||
|
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -266,6 +283,7 @@ const App: React.FC = () => {
|
||||||
const handleStartAnalysis = async (url: string) => {
|
const handleStartAnalysis = async (url: string) => {
|
||||||
if (!url.trim()) return;
|
if (!url.trim()) return;
|
||||||
|
|
||||||
|
setAfterLoadTarget('analysis');
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -291,6 +309,7 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
// 업체명 자동완성으로 분석 시작
|
// 업체명 자동완성으로 분석 시작
|
||||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||||
|
setAfterLoadTarget('analysis');
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -316,6 +335,7 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
const handleManualInput = async (businessName: string, address: string) => {
|
const handleManualInput = async (businessName: string, address: string) => {
|
||||||
|
setAfterLoadTarget('generation_flow');
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -442,7 +462,7 @@ const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<LoadingSection
|
<LoadingSection
|
||||||
isComplete={isAnalysisComplete}
|
isComplete={isAnalysisComplete}
|
||||||
onComplete={() => setViewMode('analysis')}
|
onComplete={() => setViewMode(afterLoadTarget)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CitySelectModal, { REGIONS } from './CitySelectModal';
|
||||||
|
|
||||||
interface BusinessNameInputModalProps {
|
interface BusinessNameInputModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -9,7 +11,9 @@ interface BusinessNameInputModalProps {
|
||||||
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [businessName, setBusinessName] = useState('');
|
const [businessName, setBusinessName] = useState('');
|
||||||
const [address, setAddress] = useState('');
|
const [selectedCity, setSelectedCity] = useState('');
|
||||||
|
const [detailAddress, setDetailAddress] = useState('');
|
||||||
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
@ -25,11 +29,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const isValid = businessName.trim().length > 0 && address.trim().length > 0;
|
const handleCitySelect = (city: string) => {
|
||||||
|
const region = REGIONS.find(r => r.cities.includes(city));
|
||||||
|
// 특별시/광역시는 도 이름 없이 도시명만 사용
|
||||||
|
const prefix = region && region.label !== '특별시 / 광역시' ? `${region.label} ` : '';
|
||||||
|
setSelectedCity(`${prefix}${city}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = businessName.trim().length > 0 && selectedCity.length > 0 && detailAddress.trim().length > 0;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
onSubmit(businessName.trim(), address.trim());
|
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
||||||
|
onSubmit(businessName.trim(), fullAddress);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +49,11 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
if (e.key === 'Enter' && isValid) handleSubmit();
|
if (e.key === 'Enter' && isValid) handleSubmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// CitySelectModal의 selected prop용 — 도시명만 추출
|
||||||
|
const cityOnly = selectedCity.split(' ').pop() ?? '';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
<div className="manual-modal-backdrop" onClick={onClose}>
|
<div className="manual-modal-backdrop" onClick={onClose}>
|
||||||
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="manual-modal-header">
|
<div className="manual-modal-header">
|
||||||
|
|
@ -61,13 +77,27 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
<div className="manual-modal-field">
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
|
<label className="manual-modal-label">{t('landing.hero.manualLabelRegion')}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`manual-modal-city-btn ${selectedCity ? 'selected' : ''}`}
|
||||||
|
onClick={() => setIsCityModalOpen(true)}
|
||||||
|
>
|
||||||
|
<span>{selectedCity || t('landing.hero.manualPlaceholderRegion')}</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="manual-modal-field">
|
||||||
|
<label className="manual-modal-label">{t('landing.hero.manualLabelDetail')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="manual-modal-input"
|
className="manual-modal-input"
|
||||||
placeholder={t('landing.hero.manualPlaceholderAddress')}
|
placeholder={t('landing.hero.manualPlaceholderDetail')}
|
||||||
value={address}
|
value={detailAddress}
|
||||||
onChange={e => setAddress(e.target.value)}
|
onChange={e => setDetailAddress(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
|
|
@ -89,6 +119,16 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCityModalOpen && (
|
||||||
|
<CitySelectModal
|
||||||
|
selected={cityOnly}
|
||||||
|
onSelect={(city) => { handleCitySelect(city); setIsCityModalOpen(false); }}
|
||||||
|
onClose={() => setIsCityModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
const REGIONS: { label: string; cities: string[] }[] = [
|
export const REGIONS: { label: string; cities: string[] }[] = [
|
||||||
{
|
{
|
||||||
label: '특별시 / 광역시',
|
label: '특별시 / 광역시',
|
||||||
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
|
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
|
||||||
|
|
@ -88,10 +88,11 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
|
||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? [];
|
const activeEntry = REGIONS.find(r => r.label === activeRegion);
|
||||||
|
const cities = activeEntry?.cities ?? [];
|
||||||
|
|
||||||
const handleCityClick = (city: string) => {
|
const handleSelect = (value: string) => {
|
||||||
onSelect(city === selected ? '' : city);
|
onSelect(value === selected ? '' : value);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,25 +113,38 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
|
||||||
|
|
||||||
{activeRegion === null ? (
|
{activeRegion === null ? (
|
||||||
<div className="city-modal-grid">
|
<div className="city-modal-grid">
|
||||||
{REGIONS.map(r => (
|
{REGIONS.map(r => {
|
||||||
|
const isRegionSelected = selected === r.label;
|
||||||
|
const hasCitySelected = r.cities.includes(selected);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={r.label}
|
key={r.label}
|
||||||
className={`city-modal-region-item ${r.cities.includes(selected) ? 'has-selected' : ''}`}
|
className={`city-modal-region-item ${isRegionSelected || hasCitySelected ? 'has-selected' : ''}`}
|
||||||
onClick={() => setActiveRegion(r.label)}
|
onClick={() => setActiveRegion(r.label)}
|
||||||
>
|
>
|
||||||
<span>{r.label}</span>
|
<span>{r.label}</span>
|
||||||
{r.cities.includes(selected) && <span className="city-modal-region-badge">{selected}</span>}
|
{isRegionSelected && <span className="city-modal-region-badge">전체</span>}
|
||||||
|
{hasCitySelected && <span className="city-modal-region-badge">{selected}</span>}
|
||||||
<span className="city-modal-arrow">›</span>
|
<span className="city-modal-arrow">›</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="city-modal-item-wrap">
|
<div className="city-modal-item-wrap">
|
||||||
|
{activeRegion !== '특별시 / 광역시' && (
|
||||||
|
<button
|
||||||
|
className={`city-modal-item city-modal-item-all ${selected === activeRegion ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelect(activeRegion!)}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{cities.map(city => (
|
{cities.map(city => (
|
||||||
<button
|
<button
|
||||||
key={city}
|
key={city}
|
||||||
className={`city-modal-item ${selected === city ? 'active' : ''}`}
|
className={`city-modal-item ${selected === city ? 'active' : ''}`}
|
||||||
onClick={() => handleCityClick(city)}
|
onClick={() => handleSelect(city)}
|
||||||
>
|
>
|
||||||
{city}
|
{city}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
||||||
|
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
||||||
|
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
||||||
|
import BusinessNameInputModal from './BusinessNameInputModal';
|
||||||
|
|
||||||
|
type SearchType = 'url' | 'name' | 'manual';
|
||||||
|
|
||||||
|
export type { SearchType };
|
||||||
|
|
||||||
|
const isValidUrl = (string: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(string);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SearchInputFormProps {
|
||||||
|
onAnalyze?: (value: string, type: SearchType) => void;
|
||||||
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
|
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
||||||
|
onManualButtonClick?: () => void;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
|
onAnalyze,
|
||||||
|
onAutocomplete,
|
||||||
|
onManualInput,
|
||||||
|
onManualButtonClick,
|
||||||
|
error: externalError,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('name');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
||||||
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getPlaceholder = () =>
|
||||||
|
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
|
||||||
|
|
||||||
|
const getGuideText = () =>
|
||||||
|
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
|
||||||
|
|
||||||
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || searchType !== 'name') {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsAutocompleteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchAccommodation(query);
|
||||||
|
setAutocompleteResults(response.items || []);
|
||||||
|
setShowAutocomplete(response.items && response.items.length > 0);
|
||||||
|
} catch {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} finally {
|
||||||
|
setIsAutocompleteLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
|
setInputValue(item.title.replace(/<[^>]*>/g, ''));
|
||||||
|
setSelectedItem(item);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (showAutocomplete && autocompleteResults.length > 0) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
||||||
|
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
||||||
|
setLocalError(t('landing.hero.errorInvalidUrl'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError('');
|
||||||
|
|
||||||
|
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
||||||
|
onAutocomplete({
|
||||||
|
address: selectedItem.address,
|
||||||
|
roadAddress: selectedItem.roadAddress,
|
||||||
|
title: selectedItem.title,
|
||||||
|
});
|
||||||
|
} else if (onAnalyze) {
|
||||||
|
onAnalyze(inputValue, searchType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualButtonClick = () => {
|
||||||
|
if (onManualButtonClick) {
|
||||||
|
onManualButtonClick();
|
||||||
|
} else {
|
||||||
|
setIsManualModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = externalError || localError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hero-form">
|
||||||
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
||||||
|
{/* 드롭다운 */}
|
||||||
|
<div className="hero-dropdown-container" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hero-dropdown-trigger"
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="hero-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchType(option.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 필드 */}
|
||||||
|
<div className="hero-input-container" ref={autocompleteRef}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
if (searchType === 'url') {
|
||||||
|
const urlMatch = value.match(/https?:\/\/\S+/);
|
||||||
|
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
|
||||||
|
}
|
||||||
|
setInputValue(value);
|
||||||
|
setSelectedItem(null);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
hideOnInput(value);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (searchType !== 'url') return;
|
||||||
|
const pasted = e.clipboardData.getData('text');
|
||||||
|
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue(urlMatch[0]);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
|
||||||
|
openHistory(inputValue);
|
||||||
|
}}
|
||||||
|
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 최근 검색 히스토리 */}
|
||||||
|
{showHistory && !showAutocomplete && (
|
||||||
|
<SearchHistoryDropdown
|
||||||
|
items={filteredHistory}
|
||||||
|
onSelect={(item) => {
|
||||||
|
closeHistory();
|
||||||
|
if (item.type === 'url') {
|
||||||
|
setSearchType('url');
|
||||||
|
setInputValue(item.value);
|
||||||
|
} else {
|
||||||
|
setSearchType('name');
|
||||||
|
setInputValue(item.value);
|
||||||
|
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
|
||||||
|
className="hero-autocomplete-dropdown"
|
||||||
|
itemClassName="hero-autocomplete-item"
|
||||||
|
titleClassName="hero-autocomplete-title"
|
||||||
|
addressClassName="hero-autocomplete-address"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자동완성 결과 */}
|
||||||
|
{showAutocomplete && searchType === 'name' && (
|
||||||
|
<div className="hero-autocomplete-dropdown">
|
||||||
|
{isAutocompleteLoading ? (
|
||||||
|
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
||||||
|
) : (
|
||||||
|
autocompleteResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); handleSelectAutocomplete(item); }}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
|
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 clear 버튼 */}
|
||||||
|
{inputValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hero-input-clear"
|
||||||
|
onClick={() => {
|
||||||
|
setInputValue('');
|
||||||
|
setSelectedItem(null);
|
||||||
|
setLocalError('');
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hero-input-hint">{getGuideText()}</span>
|
||||||
|
|
||||||
|
{error && <p className="hero-error">{error}</p>}
|
||||||
|
|
||||||
|
<button onClick={handleStart} className="hero-button">
|
||||||
|
{t('landing.hero.analyzeButton')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
|
||||||
|
{t('landing.hero.searchTypeManual')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
||||||
|
{!onManualButtonClick && isManualModalOpen && (
|
||||||
|
<BusinessNameInputModal
|
||||||
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
|
onSubmit={(businessName, address) => {
|
||||||
|
setIsManualModalOpen(false);
|
||||||
|
onManualInput?.(businessName, address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchInputForm;
|
||||||
|
|
@ -143,7 +143,11 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryBind = (): boolean => {
|
const tryBind = (): boolean => {
|
||||||
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
|
const els = Array.from(document.querySelectorAll(hint.targetSelector));
|
||||||
|
const el = (els.find(e => {
|
||||||
|
const r = (e as HTMLElement).getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
}) ?? els[0]) as HTMLElement | null;
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
bindToTarget(el);
|
bindToTarget(el);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,15 @@ export const tutorialSteps: TutorialStepDef[] = [
|
||||||
noSpotlight: true,
|
noSpotlight: true,
|
||||||
variant: 'bubble',
|
variant: 'bubble',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-manual-button',
|
||||||
|
titleKey: 'tutorial.landing.manual.title',
|
||||||
|
descriptionKey: 'tutorial.landing.manual.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
noSpotlight: true,
|
||||||
|
variant: 'bubble',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
targetSelector: '.hero-button',
|
targetSelector: '.hero-button',
|
||||||
titleKey: 'tutorial.landing.button.title',
|
titleKey: 'tutorial.landing.button.title',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
||||||
|
|
||||||
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
||||||
|
|
@ -189,6 +189,16 @@ export function useTutorial(): UseTutorialReturn {
|
||||||
return getSeenKeys().includes(key);
|
return getSeenKeys().includes(key);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 영상 보유 사용자 자동 off 이벤트 수신
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setIsActive(false);
|
||||||
|
setIsEnabled(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('ado2-tutorial-auto-disable', handler);
|
||||||
|
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isActive,
|
isActive,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
||||||
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
|
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
|
||||||
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
|
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
|
||||||
|
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
||||||
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
|
|
@ -174,8 +175,12 @@
|
||||||
"manualModalTitle": "Enter Business Info",
|
"manualModalTitle": "Enter Business Info",
|
||||||
"manualLabelName": "Business Name",
|
"manualLabelName": "Business Name",
|
||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
|
"manualLabelRegion": "Region",
|
||||||
|
"manualLabelDetail": "Detail Address",
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address"
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
|
"manualPlaceholderRegion": "Select a region",
|
||||||
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Welcome to ADO2.AI",
|
"title": "Welcome to ADO2.AI",
|
||||||
|
|
@ -208,11 +213,19 @@
|
||||||
"testDataLoading": "Loading...",
|
"testDataLoading": "Loading...",
|
||||||
"testData": "Test Data",
|
"testData": "Test Data",
|
||||||
"searchTypeManual": "Manual Input",
|
"searchTypeManual": "Manual Input",
|
||||||
|
"errorSelectFromList": "Please select a business from the list.",
|
||||||
|
"errorUrlRequired": "Please enter a URL.",
|
||||||
|
"errorNameRequired": "Please enter a business name.",
|
||||||
|
"errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)",
|
||||||
"manualModalTitle": "Enter Business Info",
|
"manualModalTitle": "Enter Business Info",
|
||||||
"manualLabelName": "Business Name",
|
"manualLabelName": "Business Name",
|
||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
|
"manualLabelRegion": "Region",
|
||||||
|
"manualLabelDetail": "Detail Address",
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address"
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
|
"manualPlaceholderRegion": "Select a region",
|
||||||
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "Brand Assets",
|
"title": "Brand Assets",
|
||||||
|
|
@ -434,7 +447,7 @@
|
||||||
"title": "My Info",
|
"title": "My Info",
|
||||||
"tabBasic": "Basic Info",
|
"tabBasic": "Basic Info",
|
||||||
"tabPayment": "Payment Info",
|
"tabPayment": "Payment Info",
|
||||||
"tabBusiness": "My Business & Social Channel Management",
|
"tabBusiness": "Social Channel Management",
|
||||||
"basicPlaceholder": "Basic information settings are coming soon.",
|
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||||
"myBusiness": "My Business",
|
"myBusiness": "My Business",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||||
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
||||||
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
|
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
|
||||||
|
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
||||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
|
|
@ -55,7 +56,7 @@
|
||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
||||||
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
|
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
|
||||||
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
||||||
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
||||||
},
|
},
|
||||||
|
|
@ -174,8 +175,12 @@
|
||||||
"manualModalTitle": "업체 정보 입력",
|
"manualModalTitle": "업체 정보 입력",
|
||||||
"manualLabelName": "업체명",
|
"manualLabelName": "업체명",
|
||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
|
"manualLabelRegion": "지역",
|
||||||
|
"manualLabelDetail": "상세 주소",
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요."
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||||
|
|
@ -208,11 +213,19 @@
|
||||||
"testDataLoading": "로딩 중...",
|
"testDataLoading": "로딩 중...",
|
||||||
"testData": "테스트 데이터",
|
"testData": "테스트 데이터",
|
||||||
"searchTypeManual": "직접 입력",
|
"searchTypeManual": "직접 입력",
|
||||||
|
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
||||||
|
"errorUrlRequired": "URL을 입력해주세요.",
|
||||||
|
"errorNameRequired": "업체명을 입력해주세요.",
|
||||||
|
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
||||||
"manualModalTitle": "업체 정보 입력",
|
"manualModalTitle": "업체 정보 입력",
|
||||||
"manualLabelName": "업체명",
|
"manualLabelName": "업체명",
|
||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
|
"manualLabelRegion": "지역",
|
||||||
|
"manualLabelDetail": "상세 주소",
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요."
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "브랜드 에셋",
|
"title": "브랜드 에셋",
|
||||||
|
|
@ -434,7 +447,7 @@
|
||||||
"title": "내 정보",
|
"title": "내 정보",
|
||||||
"tabBasic": "기본 정보",
|
"tabBasic": "기본 정보",
|
||||||
"tabPayment": "결제 정보",
|
"tabPayment": "결제 정보",
|
||||||
"tabBusiness": "내 비즈니스 & 소셜 채널 관리",
|
"tabBusiness": "소셜 채널 관리",
|
||||||
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||||
"myBusiness": "내 비즈니스",
|
"myBusiness": "내 비즈니스",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="loading-logo">
|
<div className="loading-logo">
|
||||||
<img src="/assets/images/ado2-loading-logo.svg" alt="ADO2" />
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading Spinner and Text */}
|
{/* Loading Spinner and Text */}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
||||||
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
||||||
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
||||||
];
|
];
|
||||||
|
|
@ -205,11 +205,11 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 컨텐츠 */}
|
||||||
<div className="myinfo-content">
|
<div className="myinfo-content">
|
||||||
{activeTab === 'basic' && (
|
{/* {activeTab === 'basic' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{activeTab === 'payment' && (
|
{activeTab === 'payment' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
|
|
@ -240,7 +240,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
|
|
||||||
{activeTab === 'business' && (
|
{activeTab === 'business' && (
|
||||||
<>
|
<>
|
||||||
{/* 내 비즈니스 섹션 */}
|
{/* 내 비즈니스 섹션
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
|
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
|
||||||
<div className="myinfo-business-card">
|
<div className="myinfo-business-card">
|
||||||
|
|
@ -261,7 +261,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* 소셜 채널 섹션 */}
|
{/* 소셜 채널 섹션 */}
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
|
||||||
|
|
||||||
const MAX_RETRY_COUNT = 3;
|
const MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
const sanitizeError = (message: string, fallback: string): string => {
|
||||||
|
if (
|
||||||
|
message.includes('Traceback') ||
|
||||||
|
message.includes('File "/') ||
|
||||||
|
message.includes('httpx.') ||
|
||||||
|
message.includes('httpcore.') ||
|
||||||
|
message.length > 200
|
||||||
|
) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
const LANGUAGE_FLAGS: Record<string, string> = {
|
const LANGUAGE_FLAGS: Record<string, string> = {
|
||||||
'한국어': '🇰🇷',
|
'한국어': '🇰🇷',
|
||||||
'English': '🇺🇸',
|
'English': '🇺🇸',
|
||||||
|
|
@ -131,7 +144,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
setErrorMessage(sanitizeError(
|
||||||
|
error instanceof Error ? error.message : '',
|
||||||
|
t('soundStudio.musicGenerationError')
|
||||||
|
));
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +184,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Song regeneration failed:', error);
|
console.error('Song regeneration failed:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
|
setErrorMessage(sanitizeError(
|
||||||
|
error instanceof Error ? error.message : '',
|
||||||
|
t('soundStudio.songRegenerationError')
|
||||||
|
));
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -362,7 +381,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Music generation failed:', error);
|
console.error('Music generation failed:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
setErrorMessage(sanitizeError(
|
||||||
|
error instanceof Error ? error.message : '',
|
||||||
|
t('soundStudio.musicGenerationError')
|
||||||
|
));
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -496,8 +518,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{['한국어', 'English', '中文'].map((lang) => (
|
{['한국어', 'English', '中文'].map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
onClick={() => !isGenerating && setSelectedLang(lang)}
|
||||||
disabled={isGenerating || selectedType === '배경음악'}
|
disabled={isGenerating}
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||||
|
|
@ -508,8 +530,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
onClick={() => !isGenerating && setSelectedLang(lang)}
|
||||||
disabled={isGenerating || selectedType === '배경음악'}
|
disabled={isGenerating}
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
import { AutocompleteRequest } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
import { CrawlingResponse } from '../../types/api';
|
||||||
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
||||||
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
|
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
|
||||||
|
|
||||||
type SearchType = 'url' | 'name' | 'manual';
|
|
||||||
|
|
||||||
// 환경변수에서 테스트 모드 확인
|
// 환경변수에서 테스트 모드 확인
|
||||||
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
||||||
|
|
@ -21,34 +17,8 @@ interface UrlInputContentProps {
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>('name');
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
|
||||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
|
||||||
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
|
|
||||||
|
|
||||||
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
|
|
||||||
closeHistory();
|
|
||||||
if (item.type === 'url') {
|
|
||||||
setSearchType('url');
|
|
||||||
setInputValue(item.value);
|
|
||||||
} else {
|
|
||||||
setSearchType('name');
|
|
||||||
setInputValue(item.value);
|
|
||||||
setSelectedItem({
|
|
||||||
title: item.value,
|
|
||||||
address: item.address || '',
|
|
||||||
roadAddress: item.roadAddress || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테스트 데이터 로드 핸들러
|
|
||||||
const handleTestData = async () => {
|
const handleTestData = async () => {
|
||||||
if (!onTestData) return;
|
if (!onTestData) return;
|
||||||
setIsLoadingTest(true);
|
setIsLoadingTest(true);
|
||||||
|
|
@ -63,269 +33,21 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
setIsLoadingTest(false);
|
setIsLoadingTest(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const searchTypeOptions = [
|
|
||||||
{ value: 'url' as SearchType, label: 'URL' },
|
|
||||||
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
|
|
||||||
// { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
|
||||||
return searchType === 'url'
|
|
||||||
? 'https://naver.me/abcdef'
|
|
||||||
: t('urlInput.placeholderBusinessName');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGuideText = () => {
|
|
||||||
return searchType === 'url'
|
|
||||||
? t('urlInput.guideUrl')
|
|
||||||
: t('urlInput.guideBusinessName');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 업체명 검색 시 자동완성 (디바운스 적용)
|
|
||||||
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
|
||||||
if (!query.trim() || searchType !== 'name') {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAutocompleteLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await searchAccommodation(query);
|
|
||||||
setAutocompleteResults(response.items || []);
|
|
||||||
setShowAutocomplete(response.items && response.items.length > 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('자동완성 검색 오류:', error);
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
} finally {
|
|
||||||
setIsAutocompleteLoading(false);
|
|
||||||
}
|
|
||||||
}, [searchType]);
|
|
||||||
|
|
||||||
// 자동완성 항목 선택 - 업체 정보 저장
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
|
||||||
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
|
||||||
setSelectedItem(item); // 선택된 업체 정보 저장
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 키보드 네비게이션 핸들러
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showAutocomplete || autocompleteResults.length === 0) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev =>
|
|
||||||
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev =>
|
|
||||||
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 붙여넣기 시 URL만 추출
|
|
||||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
if (searchType !== 'url') return;
|
|
||||||
const pasted = e.clipboardData.getData('text');
|
|
||||||
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
|
||||||
if (urlMatch) {
|
|
||||||
e.preventDefault();
|
|
||||||
setInputValue(urlMatch[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 제출 처리
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnalyzeClick = () => {
|
|
||||||
if (!inputValue.trim()) return;
|
|
||||||
|
|
||||||
if (searchType === 'name') {
|
|
||||||
if (selectedItem && onAutocomplete) {
|
|
||||||
const request: AutocompleteRequest = {
|
|
||||||
address: selectedItem.address,
|
|
||||||
roadAddress: selectedItem.roadAddress,
|
|
||||||
title: selectedItem.title,
|
|
||||||
};
|
|
||||||
onAutocomplete(request);
|
|
||||||
}
|
|
||||||
// selectedItem 없으면 아무것도 하지 않음 (드롭다운에서 선택 필요)
|
|
||||||
} else {
|
|
||||||
// URL 검색인 경우 기존 로직
|
|
||||||
onAnalyze(inputValue.trim(), searchType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
{/* 로고 */}
|
{/* 로고 */}
|
||||||
<div className="url-input-logo">
|
<div className="url-input-logo">
|
||||||
<img src="/assets/images/ado2-logo.svg" alt="ADO2" />
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL 입력 폼 */}
|
<SearchInputForm
|
||||||
<form onSubmit={handleSubmit} className="url-input-form">
|
onAnalyze={onAnalyze}
|
||||||
<div className="url-input-wrapper">
|
onAutocomplete={onAutocomplete}
|
||||||
{/* 드롭다운 */}
|
onManualInput={onManualInput}
|
||||||
<div className="url-input-dropdown-container">
|
error={error}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="url-input-dropdown-trigger"
|
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
||||||
>
|
|
||||||
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<div className="url-input-dropdown-menu">
|
|
||||||
{searchTypeOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (option.value === 'manual') {
|
|
||||||
setIsManualModalOpen(true);
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
} else {
|
|
||||||
setSearchType(option.value);
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력 필드 */}
|
|
||||||
<div className="url-input-field-container" ref={autocompleteRef}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
let value = e.target.value;
|
|
||||||
|
|
||||||
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
|
|
||||||
if (searchType === 'url') {
|
|
||||||
const urlMatch = value.match(/https?:\/\/\S+/);
|
|
||||||
if (urlMatch && urlMatch[0] !== value) {
|
|
||||||
value = urlMatch[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue(value);
|
|
||||||
hideOnInput(value);
|
|
||||||
|
|
||||||
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
|
||||||
if (searchType === 'name') {
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
handleAutocompleteSearch(value);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onFocus={() => {
|
|
||||||
if (searchType === 'name' && autocompleteResults.length > 0) {
|
|
||||||
setShowAutocomplete(true);
|
|
||||||
}
|
|
||||||
openHistory(inputValue);
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => closeHistory(), 150)}
|
|
||||||
placeholder={getPlaceholder()}
|
|
||||||
className="url-input-field"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 최근 검색 히스토리 */}
|
|
||||||
{showHistory && !showAutocomplete && (
|
|
||||||
<SearchHistoryDropdown
|
|
||||||
items={filteredHistory}
|
|
||||||
onSelect={handleSelectHistory}
|
|
||||||
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자동완성 결과 */}
|
|
||||||
{showAutocomplete && searchType === 'name' && (
|
|
||||||
<div className="url-input-autocomplete-dropdown">
|
|
||||||
{isAutocompleteLoading ? (
|
|
||||||
<div className="url-input-autocomplete-loading">{t('urlInput.searching')}</div>
|
|
||||||
) : (
|
|
||||||
autocompleteResults.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
className="url-input-autocomplete-item"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelectAutocomplete(item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
|
||||||
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 안내 텍스트 */}
|
|
||||||
<p className="url-input-guide">
|
|
||||||
{getGuideText()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<p className="url-input-error">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 검색 버튼 */}
|
|
||||||
<button type="button" onClick={handleAnalyzeClick} className="url-input-button">
|
|
||||||
{t('landing.hero.analyzeButton')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
||||||
|
|
@ -338,13 +60,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
|
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,19 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
import { CrawlingResponse } from '../../types/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
|
||||||
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
|
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
||||||
|
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
type SearchType = 'url' | 'name' | 'manual';
|
import SearchInputForm from '../../components/SearchInputForm';
|
||||||
|
import { SearchType } from '../../components/SearchInputForm';
|
||||||
|
|
||||||
// 환경변수에서 테스트 모드 확인
|
// 환경변수에서 테스트 모드 확인
|
||||||
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
||||||
|
|
||||||
interface HeroSectionProps {
|
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
|
||||||
onTestData?: (data: CrawlingResponse) => void;
|
|
||||||
onNext?: () => void;
|
|
||||||
error?: string | null;
|
|
||||||
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidUrl = (string: string): boolean => {
|
|
||||||
try {
|
|
||||||
const url = new URL(string);
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Orb configuration with movement zones to prevent overlap
|
// Orb configuration with movement zones to prevent overlap
|
||||||
interface OrbConfig {
|
interface OrbConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -41,66 +21,39 @@ interface OrbConfig {
|
||||||
initialX: number;
|
initialX: number;
|
||||||
initialY: number;
|
initialY: number;
|
||||||
color: string;
|
color: string;
|
||||||
// Movement bounds for each orb
|
|
||||||
minX: number;
|
minX: number;
|
||||||
maxX: number;
|
maxX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
maxY: number;
|
maxY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
|
|
||||||
const orbConfigs: OrbConfig[] = [
|
const orbConfigs: OrbConfig[] = [
|
||||||
// Top-left zone
|
|
||||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
||||||
// Top-right zone
|
|
||||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
||||||
// Middle-left zone
|
|
||||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
||||||
// Middle-right zone
|
|
||||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
||||||
// Bottom-left zone
|
|
||||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
||||||
// Bottom-right zone
|
|
||||||
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||||
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
|
onTestData?: (data: CrawlingResponse) => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
error?: string | null;
|
||||||
|
scrollProgress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>('name');
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [localError, setLocalError] = useState('');
|
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||||
|
const [testError, setTestError] = useState('');
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
||||||
|
|
||||||
// 테스트 데이터 로드 핸들러
|
|
||||||
const handleTestData = async () => {
|
|
||||||
if (!onTestData) return;
|
|
||||||
setIsLoadingTest(true);
|
|
||||||
try {
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
const response = await fetch(jsonFile);
|
|
||||||
const data: CrawlingResponse = await response.json();
|
|
||||||
onTestData(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('테스트 데이터 로드 실패:', error);
|
|
||||||
setLocalError(t('landing.hero.testDataLoadFailed'));
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTest(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
|
||||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const tutorial = useTutorial();
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
// 첫 방문 시 랜딩 튜토리얼 시작
|
||||||
|
|
@ -113,101 +66,28 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const searchTypeOptions = [
|
// 테스트 데이터 로드 핸들러
|
||||||
{ value: 'url' as SearchType, label: 'URL' },
|
const handleTestData = async () => {
|
||||||
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
if (!onTestData) return;
|
||||||
// { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
|
setIsLoadingTest(true);
|
||||||
];
|
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 감지
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}
|
|
||||||
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 업체명 검색 시 자동완성 (디바운스 적용)
|
|
||||||
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
|
||||||
if (!query.trim() || searchType !== 'name') {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAutocompleteLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await searchAccommodation(query);
|
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||||
setAutocompleteResults(response.items || []);
|
const response = await fetch(jsonFile);
|
||||||
setShowAutocomplete(response.items && response.items.length > 0);
|
const data: CrawlingResponse = await response.json();
|
||||||
} catch (error) {
|
onTestData(data);
|
||||||
console.error('자동완성 검색 오류:', error);
|
} catch {
|
||||||
setAutocompleteResults([]);
|
setTestError(t('landing.hero.testDataLoadFailed'));
|
||||||
setShowAutocomplete(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutocompleteLoading(false);
|
setIsLoadingTest(false);
|
||||||
}
|
|
||||||
}, [searchType]);
|
|
||||||
|
|
||||||
// 자동완성 항목 선택 - 업체 정보 저장
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
|
||||||
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
|
||||||
setSelectedItem(item); // 선택된 업체 정보 저장
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 키보드 네비게이션 핸들러
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
|
|
||||||
if (showAutocomplete && autocompleteResults.length > 0) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev =>
|
|
||||||
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev =>
|
|
||||||
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault(); // 항상 Enter 기본 동작 방지
|
|
||||||
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
|
||||||
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Random movement for orbs
|
// Orb 랜덤 이동 애니메이션
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||||
const config = orbConfigs[index];
|
const config = orbConfigs[index];
|
||||||
let currentX = config.initialX;
|
let currentX = config.initialX;
|
||||||
let currentY = config.initialY;
|
let currentY = config.initialY;
|
||||||
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
|
|
||||||
let targetX = currentX;
|
let targetX = currentX;
|
||||||
let targetY = currentY;
|
let targetY = currentY;
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
|
|
@ -215,17 +95,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
let isFirstMove = true;
|
let isFirstMove = true;
|
||||||
|
|
||||||
const generateNewTarget = () => {
|
const generateNewTarget = () => {
|
||||||
// Move within the orb's designated zone
|
|
||||||
const rangeX = config.maxX - config.minX;
|
const rangeX = config.maxX - config.minX;
|
||||||
const rangeY = config.maxY - config.minY;
|
const rangeY = config.maxY - config.minY;
|
||||||
|
|
||||||
if (isFirstMove) {
|
if (isFirstMove) {
|
||||||
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
|
|
||||||
const smallRangeX = rangeX * 0.3;
|
const smallRangeX = rangeX * 0.3;
|
||||||
const smallRangeY = rangeY * 0.3;
|
const smallRangeY = rangeY * 0.3;
|
||||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
||||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
||||||
// 범위 내로 클램핑
|
|
||||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
||||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
||||||
isFirstMove = false;
|
isFirstMove = false;
|
||||||
|
|
@ -233,94 +109,38 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
targetX = config.minX + Math.random() * rangeX;
|
targetX = config.minX + Math.random() * rangeX;
|
||||||
targetY = config.minY + Math.random() * rangeY;
|
targetY = config.minY + Math.random() * rangeY;
|
||||||
}
|
}
|
||||||
targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위)
|
targetScale = 0.9 + Math.random() * 0.2;
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
// Slow speed - 일정한 속도로 부드럽게
|
|
||||||
const speed = 0.003;
|
const speed = 0.003;
|
||||||
currentX += (targetX - currentX) * speed;
|
currentX += (targetX - currentX) * speed;
|
||||||
currentY += (targetY - currentY) * speed;
|
currentY += (targetY - currentY) * speed;
|
||||||
scale += (targetScale - scale) * speed;
|
scale += (targetScale - scale) * speed;
|
||||||
|
|
||||||
// Apply transform
|
|
||||||
orb.style.left = `${currentX}%`;
|
orb.style.left = `${currentX}%`;
|
||||||
orb.style.top = `${currentY}%`;
|
orb.style.top = `${currentY}%`;
|
||||||
orb.style.transform = `scale(${scale})`;
|
orb.style.transform = `scale(${scale})`;
|
||||||
|
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
||||||
// Generate new target when close enough
|
if (distance < 1) generateNewTarget();
|
||||||
const distance = Math.sqrt(
|
|
||||||
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
|
|
||||||
);
|
|
||||||
if (distance < 1) {
|
|
||||||
generateNewTarget();
|
|
||||||
}
|
|
||||||
|
|
||||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
animationRefs.current[index] = requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 첫 타겟 생성 후 애니메이션 시작
|
|
||||||
generateNewTarget();
|
generateNewTarget();
|
||||||
animate();
|
animate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
|
|
||||||
orbRefs.current.forEach((orb, index) => {
|
orbRefs.current.forEach((orb, index) => {
|
||||||
if (orb) {
|
if (orb) moveOrb(orb, index);
|
||||||
moveOrb(orb, index);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
return () => {
|
||||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const error = externalError || localError;
|
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
|
||||||
return searchType === 'url'
|
|
||||||
? 'https://naver.me/abcdef'
|
|
||||||
: t('landing.hero.placeholderBusinessName');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGuideText = () => {
|
|
||||||
return searchType === 'url'
|
|
||||||
? t('landing.hero.guideUrl')
|
|
||||||
: t('landing.hero.guideBusinessName');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = () => {
|
|
||||||
if (!inputValue.trim()) {
|
|
||||||
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
|
||||||
setLocalError(t('landing.hero.errorInvalidUrl'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalError('');
|
|
||||||
|
|
||||||
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
|
||||||
// 업체명 검색인 경우 autocomplete API 호출
|
|
||||||
const request: AutocompleteRequest = {
|
|
||||||
address: selectedItem.address,
|
|
||||||
roadAddress: selectedItem.roadAddress,
|
|
||||||
title: selectedItem.title,
|
|
||||||
};
|
|
||||||
onAutocomplete(request);
|
|
||||||
} else if (onAnalyze) {
|
|
||||||
// URL 검색인 경우 기존 로직
|
|
||||||
onAnalyze(inputValue, searchType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero-section">
|
<div className="hero-section">
|
||||||
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
|
{/* Animated background orbs */}
|
||||||
<div
|
<div
|
||||||
className="hero-bg-orbs"
|
className="hero-bg-orbs"
|
||||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
||||||
|
|
@ -342,219 +162,45 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
{/* Logo Image */}
|
|
||||||
<img
|
<img
|
||||||
src="/assets/images/ado2-logo.svg"
|
src="/assets/images/ADO2_with Slogan_white.svg"
|
||||||
alt="ADO2"
|
alt="ADO2"
|
||||||
className="hero-logo"
|
className="hero-logo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SearchInputForm
|
||||||
{/* Input Form */}
|
onAnalyze={onAnalyze}
|
||||||
<div className="hero-form">
|
onAutocomplete={onAutocomplete}
|
||||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
onManualInput={onManualInput}
|
||||||
{/* 드롭다운 */}
|
onManualButtonClick={() => {
|
||||||
<div className="hero-dropdown-container" ref={dropdownRef}>
|
if (isLoggedIn()) {
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hero-dropdown-trigger"
|
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
||||||
>
|
|
||||||
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<div className="hero-dropdown-menu">
|
|
||||||
{searchTypeOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (option.value === 'manual') {
|
|
||||||
setIsManualModalOpen(true);
|
setIsManualModalOpen(true);
|
||||||
setIsDropdownOpen(false);
|
|
||||||
} else {
|
} else {
|
||||||
setSearchType(option.value);
|
setIsLoginPromptOpen(true);
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
error={externalError || testError || null}
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hero-input-container" ref={autocompleteRef}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
let value = e.target.value;
|
|
||||||
|
|
||||||
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
|
|
||||||
if (searchType === 'url') {
|
|
||||||
const urlMatch = value.match(/https?:\/\/\S+/);
|
|
||||||
if (urlMatch && urlMatch[0] !== value) {
|
|
||||||
value = urlMatch[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue(value);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
hideOnInput(value);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
|
|
||||||
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
|
||||||
if (searchType === 'name') {
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
handleAutocompleteSearch(value);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
if (searchType !== 'url') return;
|
|
||||||
const pasted = e.clipboardData.getData('text');
|
|
||||||
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
|
||||||
if (urlMatch) {
|
|
||||||
e.preventDefault();
|
|
||||||
setInputValue(urlMatch[0]);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
setIsFocused(true);
|
|
||||||
if (searchType === 'name' && autocompleteResults.length > 0) {
|
|
||||||
setShowAutocomplete(true);
|
|
||||||
}
|
|
||||||
openHistory(inputValue);
|
|
||||||
}}
|
|
||||||
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={getPlaceholder()}
|
|
||||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 최근 검색 히스토리 */}
|
|
||||||
{showHistory && !showAutocomplete && (
|
|
||||||
<SearchHistoryDropdown
|
|
||||||
items={filteredHistory}
|
|
||||||
onSelect={(item) => {
|
|
||||||
closeHistory();
|
|
||||||
if (item.type === 'url') {
|
|
||||||
setSearchType('url');
|
|
||||||
setInputValue(item.value);
|
|
||||||
} else {
|
|
||||||
setSearchType('name');
|
|
||||||
setInputValue(item.value);
|
|
||||||
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
|
|
||||||
className="hero-autocomplete-dropdown"
|
|
||||||
itemClassName="hero-autocomplete-item"
|
|
||||||
titleClassName="hero-autocomplete-title"
|
|
||||||
addressClassName="hero-autocomplete-address"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자동완성 결과 */}
|
|
||||||
{showAutocomplete && searchType === 'name' && (
|
|
||||||
<div className="hero-autocomplete-dropdown">
|
|
||||||
{isAutocompleteLoading ? (
|
|
||||||
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
|
||||||
) : (
|
|
||||||
autocompleteResults.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelectAutocomplete(item);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
>
|
|
||||||
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
|
||||||
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{inputValue && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hero-input-clear"
|
|
||||||
onClick={() => {
|
|
||||||
setInputValue('');
|
|
||||||
setLocalError('');
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="hero-input-hint">{getGuideText()}</span>
|
|
||||||
{error && (
|
|
||||||
<p className="hero-error">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={handleStart} className="hero-button">
|
|
||||||
{t('landing.hero.analyzeButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Indicator */}
|
{/* Footer Indicator */}
|
||||||
<button onClick={onNext} className="scroll-indicator">
|
<button onClick={onNext} className="scroll-indicator">
|
||||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||||
<div className="scroll-indicator-icon">
|
<div className="scroll-indicator-icon">
|
||||||
<svg
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M7 10l5 5 5-5" />
|
<path d="M7 10l5 5 5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
{/* 테스트 버튼 */}
|
||||||
{isTestPage && onTestData && (
|
{isTestPage && onTestData && (
|
||||||
<button
|
<button onClick={handleTestData} disabled={isLoadingTest} className="test-data-button">
|
||||||
onClick={handleTestData}
|
|
||||||
disabled={isLoadingTest}
|
|
||||||
className="test-data-button"
|
|
||||||
>
|
|
||||||
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tutorial.isActive && (
|
{tutorial.isActive && !isManualModalOpen && (
|
||||||
<TutorialOverlay
|
<TutorialOverlay
|
||||||
hints={tutorial.hints}
|
hints={tutorial.hints}
|
||||||
currentIndex={tutorial.currentHintIndex}
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
|
@ -568,9 +214,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
{isManualModalOpen && (
|
{isManualModalOpen && (
|
||||||
<BusinessNameInputModal
|
<BusinessNameInputModal
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
|
onSubmit={(businessName, address) => {
|
||||||
|
if (tutorial.isActive) tutorial.nextHint();
|
||||||
|
setIsManualModalOpen(false);
|
||||||
|
onManualInput?.(businessName, address);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoginPromptOpen && (
|
||||||
|
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||||
<div className="login-content">
|
<div className="login-content">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="login-logo">
|
<div className="login-logo">
|
||||||
<img src="/assets/images/ado2-login-logo.svg" alt="ADO2" />
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue