Merge branch 'feature/20251031_jo_FX_COUPON_01' into 'feature/202501107-all'

feat: 쿠폰등록

See merge request sgp-web-d/web-template-fe!5
This commit is contained in:
김채린
2025-11-10 06:22:43 +00:00
33 changed files with 2681 additions and 16 deletions

View File

@@ -15,6 +15,10 @@ STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn-dev.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Coupon
STOVE_COUPON_URL=https://bill-dev.onstove.com/History/UserCouponList.aspx
STOVE_M_COUPON_URL=https://bill-dev.onstove.com/g-coupon/RegCouponM.aspx
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe

View File

@@ -15,6 +15,10 @@ STOVE_M_API_URL=https://maintenance.onstove.com
# STOVE - GNB
STOVE_GNB=https://js-cdn.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Coupon
STOVE_COUPON_URL=https://bill-dev.onstove.com/History/UserCouponList.aspx
STOVE_M_COUPON_URL=https://bill-dev.onstove.com/g-coupon/RegCouponM.aspx
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.onstove.com/libs/stove-js-service/latest/launcher-pack.js
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-live-dl.game.playstove.com/game/lcs/STOVESetup.exe

View File

@@ -15,6 +15,10 @@ STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn-qa.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Coupon
STOVE_COUPON_URL=https://bill-qa.onstove.com/History/UserCouponList.aspx
STOVE_M_COUPON_URL=https://bill-qa.onstove.com/g-coupon/RegCouponM.aspx
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe

View File

@@ -15,6 +15,10 @@ STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn.gate8.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Coupon
STOVE_COUPON_URL=https://bill-qa.onstove.com/History/UserCouponList.aspx
STOVE_M_COUPON_URL=https://bill-qa.onstove.com/g-coupon/RegCouponM.aspx
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
interface Props {
modelValue: string
placeholder?: string
useClearButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
useClearButton: false,
})
const emit = defineEmits(['update:modelValue', 'input', 'keydown', 'clear'])
const localValue = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const onClickClearButton = () => {
localValue.value = ''
emit('clear')
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement | null
if (target) {
localValue.value = target.value
}
emit('input')
}
</script>
<template>
<div class="group relative w-full">
<input
v-model="localValue"
:type="typeof $attrs.type === 'string' ? $attrs.type : 'text'"
:placeholder="props.placeholder"
v-bind="$attrs"
class="relative w-full h-[48px] px-[12px] outline-none border border-solid border-[1px] border-[#D9D9D9] rounded-[8px] bg-white text-left text-[#333333] text-[14px] font-[400] leading-[20px] tracking-[-0.42px] placeholder:text-[#B2B2B2] md:h-[56px] md:px-[16px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px] hover:[&:not([readonly])]:border-[#999999] focus:border-[#999999]"
@input="onInput"
@keydown="emit('keydown', $event)"
/>
<AtomsButton
v-if="props.useClearButton && localValue.length > 0"
type="action"
button-size="size-small"
background-color="#00000000"
text-color="transparent"
class="!absolute top-[50%] right-[12px] translate-y-[-50%] flex items-center justify-center w-auto h-auto p-0 md:right-[16px]"
@click="onClickClearButton"
>
<AtomsIconsCloseCircleFill :size="16" color="rgba(0,0,0,0.15)" />
</AtomsButton>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
interface Props {
options: Array<any>
labelName: string | number
modelValue?: object
placeholder?: string
defaultLabel?: string
placement?: string | 'top' | 'bottom'
className?: string
selectedColor?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: null,
placeholder: null,
defaultLabel: null,
placement: 'bottom',
className: '',
selectedColor: '#333333',
})
const emits = defineEmits(['update:modelValue'])
const isActive = ref(false)
const isSelected = ref(props.defaultLabel !== null)
const selectedOption = ref<string>(props.placeholder || props.defaultLabel)
const onToggleOpen = () => {
isActive.value = !isActive.value
}
const onSelectOption = (option: { [key: string | number]: any }): void => {
isActive.value = false
isSelected.value = true
selectedOption.value = option[props.labelName]
emits('update:modelValue', option)
}
</script>
<template>
<div class="relative w-full max-w-[432px]">
<button
type="button"
class="relative flex items-center justify-between gap-[12px] w-full py-[12px] px-[16px] bg-white border border-solid border-[1px] border-[#D9D9D9] rounded-[8px]"
@click="onToggleOpen"
>
<span
class="inline-flex items-center justify-left text-left text-[14px] font-[400] leading-[20px] tracking-[-0.42px]"
:class="isSelected ? 'text-[#333333]' : 'text-[#999999]'"
>
{{ selectedOption }}
</span>
<i
class="inline-flex items-center justify-center w-[14px] h-[14px] shrink-0"
>
<AtomsIconsSelectArrowDownFill
:size="12"
color="#333333"
:class="isActive ? 'rotate-180' : ''"
/>
</i>
</button>
<div
v-if="isActive"
:data-placement="props.placement"
class="absolute z-[10] top-full left-0 translate-y-[4px] w-full py-[8px] border border-solid border-[1px] border-[rgba(0,0,0,0.3)] rounded-[8px] bg-white shadow-[0_4px_10px_0_rgba(0,0,0,0.10)]"
>
<ul class="relative flex flex-col items-center justify-start w-full">
<li
v-for="(option, index) in props.options"
:key="String(option[props.labelName])"
class="relative flex items-center justify-left w-full"
>
<button
type="button"
class="relative flex items-center justify-left w-full py-[8px] pl-[40px] pr-[16px] bg-white text-left text-left text-[14px] font-[400] leading-[24px] tracking-[-0.42px] hover:bg-[rgba(0,0,0,0.04)]"
:class="
selectedColor ? `text-[${selectedColor}]` : 'text-[#333333]'
"
@click="onSelectOption(option)"
>
<template v-if="option[props.labelName] === selectedOption">
<i
class="absolute top-1/2 left-[12px] translate-y-[-50%] flex items-center justify-center w-[20px] h-[20px]"
>
<AtomsIconsCheckLine :size="16" :color="selectedColor" />
</i>
</template>
<span>
{{ option[props.labelName] }}
</span>
</button>
</li>
</ul>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: 'var(--foreground-gray-500)',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 11 11"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.80474 1.13807C6.06509 0.877722 6.06509 0.455612 5.80474 0.195262C5.54439 -0.0650874 5.12228 -0.0650874 4.86193 0.195262L0.195262 4.86193C-0.0650874 5.12228 -0.0650874 5.54439 0.195262 5.80474L4.86193 10.4714C5.12228 10.7318 5.54439 10.7318 5.80474 10.4714C6.06509 10.2111 6.06509 9.78894 5.80474 9.52859L1.60948 5.33333L5.80474 1.13807Z"
:fill="color"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.4714 1.13807C10.7318 0.877722 10.7318 0.455612 10.4714 0.195262C10.2111 -0.0650874 9.78895 -0.0650874 9.5286 0.195262L4.86193 4.86193C4.60158 5.12228 4.60158 5.54439 4.86193 5.80474L9.5286 10.4714C9.78895 10.7318 10.2111 10.7318 10.4714 10.4714C10.7318 10.2111 10.7318 9.78894 10.4714 9.52859L6.27614 5.33333L10.4714 1.13807Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M5.83333 11.4583C5.83333 10.6529 5.18042 10 4.375 10C3.56958 10 2.91667 10.6529 2.91667 11.4583C2.91667 12.2637 3.56958 12.9167 4.375 12.9167C5.18041 12.9167 5.83333 12.2637 5.83333 11.4583Z"
:fill="color"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.75 0C4.21024 0 4.58333 0.373096 4.58333 0.833333V1.66667H10.4167V0.833333C10.4167 0.373096 10.7898 0 11.25 0C11.7102 0 12.0833 0.373096 12.0833 0.833333V1.66667H12.9167C14.0673 1.66667 15 2.59941 15 3.75V13.75C15 14.9006 14.0673 15.8333 12.9167 15.8333H2.08333C0.93274 15.8333 0 14.9006 0 13.75V3.75C0 2.59941 0.93274 1.66667 2.08333 1.66667H2.91667V0.833333C2.91667 0.373096 3.28976 0 3.75 0ZM10.4167 3.33333C10.4167 3.79357 10.7898 4.16667 11.25 4.16667C11.7102 4.16667 12.0833 3.79357 12.0833 3.33333H12.9167C13.1468 3.33333 13.3333 3.51988 13.3333 3.75V5.83333H1.66667V3.75C1.66667 3.51988 1.85321 3.33333 2.08333 3.33333H2.91667C2.91667 3.79357 3.28976 4.16667 3.75 4.16667C4.21024 4.16667 4.58333 3.79357 4.58333 3.33333H10.4167ZM1.66667 7.5H13.3333V13.75C13.3333 13.9801 13.1468 14.1667 12.9167 14.1667H2.08333C1.85321 14.1667 1.66667 13.9801 1.66667 13.75V7.5Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#1F1F1F',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 15 10"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13.9226 0.244078C14.248 0.569515 14.248 1.09715 13.9226 1.42259L5.58927 9.75592C5.42514 9.92006 5.19998 10.0083 4.96803 9.99939C4.73609 9.99048 4.51836 9.88523 4.3673 9.70899L0.200635 4.84788C-0.0988839 4.49844 -0.0584159 3.97236 0.291022 3.67284C0.64046 3.37332 1.16654 3.41379 1.46606 3.76323L5.04708 7.94109L12.7441 0.244078C13.0695 -0.0813592 13.5972 -0.0813592 13.9226 0.244078Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#1F1F1F',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 1.5C17.799 1.5 22.5 6.20101 22.5 12C22.5 17.799 17.799 22.5 12 22.5C6.20101 22.5 1.5 17.799 1.5 12C1.50001 6.20101 6.20102 1.5 12 1.5ZM15.7071 15.7071C16.0976 15.3166 16.0976 14.6834 15.7071 14.2929L13.4142 12L15.7071 9.70711C16.0976 9.31659 16.0976 8.68342 15.7071 8.2929C15.3166 7.90237 14.6834 7.90237 14.2929 8.2929L12 10.5858L9.70711 8.2929C9.31659 7.90237 8.68342 7.90237 8.2929 8.2929C7.90238 8.68342 7.90238 9.31658 8.2929 9.70711L10.5858 12L8.2929 14.2929C7.90237 14.6834 7.90237 15.3166 8.2929 15.7071C8.68342 16.0976 9.31659 16.0976 9.70711 15.7071L12 13.4142L14.2929 15.7071C14.6834 16.0976 15.3166 16.0976 15.7071 15.7071Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.4413 0.644656C9.58368 -0.214056 8.18151 -0.214884 7.32284 0.64217L6.09332 1.85726L6.092 1.85863C6.08921 1.86072 6.08403 1.86443 6.07608 1.86947C6.05498 1.88286 6.02495 1.89889 5.98871 1.91393C5.95257 1.92893 5.91833 1.93961 5.89062 1.94597C5.8723 1.95017 5.86194 1.95131 5.8595 1.95158H4.15691C2.93936 1.95158 1.94911 2.9448 1.94911 4.16099V5.86353C1.9487 5.86679 1.94743 5.87584 1.94397 5.89078C1.93766 5.91805 1.92684 5.9528 1.91128 5.99038C1.89572 6.02795 1.87871 6.06042 1.86357 6.08467C1.85431 6.0995 1.84819 6.10737 1.8464 6.10964L0.643348 7.31422C-0.214449 8.17311 -0.214449 9.57689 0.643348 10.4358L1.8464 11.6404C1.84819 11.6426 1.85431 11.6505 1.86357 11.6653C1.87871 11.6896 1.89572 11.722 1.91128 11.7596C1.92684 11.7972 1.93766 11.8319 1.94397 11.8592C1.94743 11.8742 1.9487 11.8832 1.94911 11.8865V13.589C1.94911 14.8052 2.93936 15.7984 4.15691 15.7984H5.85707C5.86038 15.7988 5.86923 15.8001 5.88373 15.8035C5.91079 15.8098 5.94533 15.8205 5.98275 15.8361C6.02015 15.8516 6.05249 15.8686 6.07662 15.8837C6.09134 15.8929 6.09916 15.899 6.10143 15.9008L7.30447 17.1053C8.16292 17.9649 9.56698 17.9649 10.4254 17.1053L11.6285 15.9008C11.6307 15.899 11.6385 15.8929 11.6533 15.8837C11.6774 15.8686 11.7097 15.8516 11.7471 15.8361C11.7846 15.8205 11.8191 15.8098 11.8462 15.8035C11.8607 15.8001 11.8695 15.7988 11.8728 15.7984H13.573C14.7905 15.7984 15.7808 14.8052 15.7808 13.589V11.8865C15.7812 11.8832 15.7825 11.8742 15.7859 11.8592C15.7922 11.8319 15.8031 11.7972 15.8186 11.7596C15.8342 11.722 15.8512 11.6896 15.8663 11.6653C15.8756 11.6505 15.8817 11.6426 15.8835 11.6404L17.0774 10.4449C17.9753 9.58844 17.9649 8.17522 17.1001 7.31988L15.8994 6.11759C15.8976 6.11533 15.8915 6.10746 15.8822 6.09262C15.8671 6.06837 15.8501 6.0359 15.8345 5.99833C15.8189 5.96074 15.8081 5.926 15.8018 5.89872C15.7983 5.88378 15.7971 5.87473 15.7967 5.87147V4.16099C15.7967 2.94479 14.8064 1.95158 13.5889 1.95158H11.8887C11.8854 1.95116 11.8766 1.94989 11.8621 1.94653C11.835 1.94025 11.8004 1.92947 11.763 1.91393C11.7256 1.8984 11.6933 1.88142 11.6692 1.86632C11.6544 1.85711 11.6466 1.85102 11.6444 1.84922L10.4413 0.644656ZM12.3807 5.36843C12.7468 5.73455 12.7468 6.32814 12.3807 6.69425L6.69319 12.3818C6.32707 12.7479 5.73348 12.7479 5.36736 12.3818C5.00124 12.0156 5.00124 11.422 5.36736 11.0559L11.0549 5.36843C11.421 5.00231 12.0146 5.00231 12.3807 5.36843ZM6.23574 7.25014C6.79665 7.25014 7.25136 6.79543 7.25136 6.23451C7.25136 5.6736 6.79665 5.21889 6.23574 5.21889C5.67482 5.21889 5.22011 5.6736 5.22011 6.23451C5.22011 6.79543 5.67482 7.25014 6.23574 7.25014ZM11.5141 12.5312C12.075 12.5312 12.5297 12.0765 12.5297 11.5156C12.5297 10.9547 12.075 10.5 11.5141 10.5C10.9531 10.5 10.4984 10.9547 10.4984 11.5156C10.4984 12.0765 10.9531 12.5312 11.5141 12.5312Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.83333 7.8125C5.3731 7.8125 5 8.1856 5 8.64583C5 9.10607 5.3731 9.47917 5.83333 9.47917H10.8333C11.2936 9.47917 11.6667 9.10607 11.6667 8.64583C11.6667 8.1856 11.2936 7.8125 10.8333 7.8125H5.83333Z"
:fill="color"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.33333 0C1.49238 0 0 1.49238 0 3.33333V9.58333C0 11.4243 1.49238 12.9167 3.33333 12.9167H7.5V13.6458H5.20833C4.7481 13.6458 4.375 14.0189 4.375 14.4792C4.375 14.9394 4.7481 15.3125 5.20833 15.3125H11.4583C11.9186 15.3125 12.2917 14.9394 12.2917 14.4792C12.2917 14.0189 11.9186 13.6458 11.4583 13.6458H9.16667V12.9167H13.3333C15.1743 12.9167 16.6667 11.4243 16.6667 9.58333V3.33333C16.6667 1.49238 15.1743 0 13.3333 0H3.33333ZM1.66667 3.33333C1.66667 2.41286 2.41286 1.66667 3.33333 1.66667H13.3333C14.2538 1.66667 15 2.41286 15 3.33333V9.58333C15 10.5038 14.2538 11.25 13.3333 11.25H3.33333C2.41286 11.25 1.66667 10.5038 1.66667 9.58333V3.33333Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.74512 10.6879C8.71868 11.509 7.41668 12 6 12C2.68629 12 0 9.31371 0 6C0 2.68629 2.68629 0 6 0C9.31371 0 12 2.68629 12 6C12 7.41668 11.509 8.71868 10.6879 9.74512L13.1381 12.1953C13.3984 12.4556 13.3984 12.8777 13.1381 13.1381C12.8777 13.3984 12.4556 13.3984 12.1953 13.1381L9.74512 10.6879ZM10.6667 6C10.6667 8.57733 8.57733 10.6667 6 10.6667C3.42267 10.6667 1.33333 8.57733 1.33333 6C1.33333 3.42267 3.42267 1.33333 6 1.33333C8.57733 1.33333 10.6667 3.42267 10.6667 6Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.06454 4.95333L0.204544 1.16667C-0.228789 0.74 0.0712106 0 0.684544 0L9.31787 0C9.9312 0 10.2312 0.74 9.79787 1.16667L5.93787 4.95333C5.41787 5.46 4.59121 5.46 4.07121 4.95333H4.06454Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,524 @@
<script setup lang="ts">
import {
getYear,
getMonth,
startOfDay,
globalDateFormat,
} from '@seed-next/date'
import type { PublicMethods } from '@vuepic/vue-datepicker'
interface Props {
date: Date | string | null
minDate?: Date | null
maxDate?: Date | null
placeholder?: Date | string | null
}
const props = withDefaults(defineProps<Props>(), {
minDate: null,
maxDate: null,
placeholder: null,
})
const emits = defineEmits(['update:date'])
// Composabled
const breakpoints = useResponsiveBreakpoints()
// Refs -----
const { tm, locale } = useI18n()
const datePickerRef = ref<any>(null)
const resultDate = ref('')
const formatDate = ref('')
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth())
const currentDecade = ref<{ start: number; end: number } | null>(null)
// 상태값
const isMenuOpened = ref(false)
const isShowYearPicker = ref(false)
const isShowMonthPicker = ref(false)
const isShowDecadePicker = ref(false)
// Computed
const yearMonthText = computed(() => {
return `${currentYear.value}${currentMonth.value + 1}`
})
const formatMinDate = computed(() => {
if (props.minDate !== null) {
return globalDateFormat(props.minDate, locale.value)
} else {
return undefined
}
})
const formatMaxDate = computed(() => {
if (props.maxDate !== null) {
return globalDateFormat(props.maxDate, locale.value)
} else {
return undefined
}
})
const formatPlaceholder = computed(() => {
if (props.placeholder !== null) {
return props.placeholder instanceof Date
? (globalDateFormat(props.placeholder, locale.value) as string)
: props.placeholder
} else {
return ''
}
})
// Functions
const setFormatDate = () => {
const toDay = startOfDay(new Date())
resultDate.value =
props.date === null ? toDay.toString() : props.date.toString()
formatDate.value = globalDateFormat(resultDate.value, locale.value) as string
const dateObj = props.date === null ? toDay : new Date(props.date)
currentYear.value = dateObj.getFullYear()
currentMonth.value = dateObj.getMonth()
}
const updateDate = (_date: Date | null) => {
if (_date != null) {
formatDate.value = globalDateFormat(_date, locale.value) as string
currentYear.value = _date.getFullYear()
currentMonth.value = _date.getMonth()
emits('update:date', formatDate.value)
}
}
const format = (_date: Date) => {
return globalDateFormat(_date, locale.value)
}
const handleMenuOpen = () => {
isMenuOpened.value = true
}
const handleMenuClose = () => {
isMenuOpened.value = false
isShowMonthPicker.value = false
isShowYearPicker.value = false
}
const handleOpenMonthPicker = () => {
isShowMonthPicker.value = true
isShowYearPicker.value = false
}
const handleOpenYearPicker = () => {
isShowYearPicker.value = true
isShowMonthPicker.value = false
}
const handleMonthSelect = (month: number) => {
currentMonth.value = month
isShowMonthPicker.value = false
// 선택한 년도/월로 날짜 업데이트
const newDate = new Date(currentYear.value, currentMonth.value, 1)
if (props.minDate && newDate < props.minDate) {
newDate.setDate(props.minDate.getDate())
}
if (props.maxDate && newDate > props.maxDate) {
newDate.setDate(props.maxDate.getDate())
}
// DatePicker의 내부 상태 업데이트
if (datePickerRef.value) {
datePickerRef.value.selectDate(newDate)
}
updateDate(newDate)
}
const handleYearSelect = (year: number) => {
currentYear.value = year
isShowYearPicker.value = false
// 선택한 년도/월로 날짜 업데이트
const newDate = new Date(currentYear.value, currentMonth.value, 1)
if (props.minDate && newDate < props.minDate) {
newDate.setDate(props.minDate.getDate())
}
if (props.maxDate && newDate > props.maxDate) {
newDate.setDate(props.maxDate.getDate())
}
// DatePicker의 내부 상태 업데이트
if (datePickerRef.value) {
datePickerRef.value.selectDate(newDate)
}
updateDate(newDate)
}
const generateYearList = () => {
const years = []
const minYear = props.minDate ? props.minDate.getFullYear() : 1900
const maxYear = props.maxDate ? props.maxDate.getFullYear() : 2100
for (let year = minYear; year <= maxYear; year++) {
years.push(year)
}
return years
}
const generateMonthList = () => {
const months = []
const minMonth =
props.minDate && props.minDate.getFullYear() === currentYear.value
? props.minDate.getMonth()
: 0
const maxMonth =
props.maxDate && props.maxDate.getFullYear() === currentYear.value
? props.maxDate.getMonth()
: 11
for (let month = minMonth; month <= maxMonth; month++) {
months.push(month)
}
return months
}
watch(
() => props.date,
() => {
setFormatDate()
},
{ immediate: true }
)
onMounted(() => {
setFormatDate()
})
</script>
<template>
<VueDatePicker
ref="datePickerRef"
v-model="resultDate"
:locale="locale"
week-start="0"
:year-first="true"
:auto-apply="true"
:hide-offset-dates="true"
:enable-time-picker="false"
:preview-format="format"
:min-date="formatMinDate"
:max-date="formatMaxDate"
:prevent-min-max-navigation="true"
:month-change-on-scroll="false"
position="left"
:auto-position="true"
:offset="8"
:config="{
shadowDom: true,
}"
class="date-picker-wrap"
@update:model-value="updateDate"
@date-update="updateDate"
@open="handleMenuOpen"
@closed="handleMenuClose"
>
<template #trigger>
<div
class="relative flex items-center justify-between w-full h-[40px] px-[16px] border border-solid border-[1px] border-[#D9D9D9] rounded-[8px] bg-white cursor-pointer"
>
<AtomsInput
:model-value="formatDate"
:placeholder="formatPlaceholder"
:value="formatDate"
name="date-picker-text"
label
readonly
class="inline-flex items-center justify-start !w-full !h-auto !p-0 m-0 bg-transparent border-none outline-none cursor-pointer text-left !text-[14px] text-[#333333] font-[400] !leading-[20px] !tracking-[-0.42px]"
/>
<AtomsIconsCalendarLine
:size="breakpoints.isMobile ? 16 : 20"
color="#333333"
class="absolute top-[50%] right-[12px] translate-y-[-50%] md:right-[16px]"
/>
</div>
</template>
<template #calendar-header="{ index, day }">
<div :class="index === 0 ? 'sunday' : index === 6 ? 'saturday' : ''">
{{ day }}
</div>
</template>
<template #day="{ day, date }">
<span
:class="[
date.getDay() === 0 ? 'text-[#FC4420]' : 'text-[#333333]',
date > new Date(formatMaxDate) ? 'opacity-35' : 'opacity-100',
]"
>
{{ day }}
</span>
</template>
<template
#month-year="{
month,
year,
months,
years,
updateMonthYear,
handleMonthYearChange,
isDisabled,
}"
>
<div class="flex items-center justify-between w-full py-[16px]">
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="isDisabled(false)"
@click="
updateMonthYear(
month === 0 ? 11 : month - 1,
month === 0 ? year - 1 : year,
false
)
"
>
<AtomsIconsArrowRightLine
:size="16"
:color="isDisabled(false) ? '#CCCCCC' : '#333333'"
class="relative rotate-180"
/>
</button>
<button
type="button"
class="relative inline-flex items-center justify-center gap-[8px] w-auto h-[32px]"
@click="handleOpenMonthPicker"
>
<span
class="inline-flex items-center justify-center text-[#1F1F1F] text-[18px] font-[500] leading-[26px] tracking-[-0.54px]"
>
{{ years.find(y => y.value === year)?.text }}.
{{ months.find(m => m.value === month)?.text }}
</span>
<AtomsIconsSelectArrowDownFill :size="10" color="#333333" />
</button>
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="isDisabled(true)"
@click="
updateMonthYear(
month === 11 ? 0 : month + 1,
month === 11 ? year + 1 : year,
false
)
"
>
<AtomsIconsArrowRightLine
:size="16"
:color="isDisabled(true) ? '#CCCCCC' : '#333333'"
class="relative"
/>
</button>
</div>
<div
v-if="isShowMonthPicker && isMenuOpened"
class="absolute z-[2] top-0 left-0 flex flex-col items-center justify-start w-full h-full overflow-y-auto bg-white"
>
<div class="flex items-center justify-between w-full py-[16px]">
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="isDisabled(false)"
@click="
updateMonthYear(
month,
year === getYear(minDate) ? year : year - 1,
false
)
"
>
<AtomsIconsArrowRightLine
:size="16"
:color="isDisabled(false) ? '#CCCCCC' : '#333333'"
class="relative rotate-180"
/>
</button>
<button
type="button"
class="relative inline-flex items-center justify-center gap-[8px] w-auto h-[32px]"
@click="handleOpenYearPicker"
>
<span
class="inline-flex items-center justify-center text-[#1F1F1F] text-[18px] font-[500] leading-[26px] tracking-[-0.54px]"
>
{{ years.find(y => y.value === year)?.text }}
</span>
</button>
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="isDisabled(true)"
@click="
updateMonthYear(
month,
year === getYear(maxDate) ? year : year + 1,
false
)
"
>
<AtomsIconsArrowRightLine
:size="16"
:color="isDisabled(true) ? '#CCCCCC' : '#333333'"
class="relative"
/>
</button>
</div>
<div class="grid grid-cols-3 gap-[4px] w-full">
<button
v-for="month in generateMonthList()"
:key="month"
type="button"
:class="[
'inline-flex items-center justify-center w-full h-[52px] px-[12px] py-[8px] text-center text-[14px] font-[400] leading-[20px] tracking-[-0.42px] rounded-[4px] transition-colors',
year === currentYear && month === currentMonth
? 'bg-[#FC4420] text-white'
: 'text-[#333333] hover:bg-[#F5F5F5]',
]"
@click="handleMonthSelect(month)"
>
{{ months.find(m => m.value === month)?.text }}
</button>
</div>
</div>
<div
v-if="isShowYearPicker && isMenuOpened"
class="absolute z-[2] top-0 left-0 w-full h-full mt-[8px] overflow-y-auto bg-white"
>
<div class="grid grid-cols-4 gap-[4px]">
<button
v-for="year in generateYearList()"
:key="year"
type="button"
:class="[
'px-[12px] py-[8px] text-center text-[14px] font-[400] leading-[20px] tracking-[-0.42px] rounded-[4px] transition-colors',
year === currentYear
? 'bg-[#FC4420] text-white'
: 'text-[#333333] hover:bg-[#F5F5F5]',
]"
@click="handleYearSelect(year)"
>
{{ years.find(y => y.value === year)?.text }}
</button>
</div>
</div>
</template>
</VueDatePicker>
</template>
<style scoped>
.date-picker-wrap :deep(.dp__calendar_header_separator) {
display: none !important;
}
.date-picker-wrap :deep(.dp__menu) {
padding: 0 12px 8px !important;
}
.date-picker-wrap :deep(.dp__menu_inner) {
padding: 0 !important;
}
.date-picker-wrap :deep(.dp__calendar) {
padding: 0 !important;
}
.date-picker-wrap :deep(.dp__calendar_row) {
-webkit-gap: 4px !important;
-moz-gap: 4px !important;
-o-gap: 4px !important;
-ms-gap: 4px !important;
gap: 4px !important;
margin: 0 !important;
}
.date-picker-wrap :deep(.dp__calendar_item) {
padding: 2px 4px !important;
}
.date-picker-wrap :deep(.dp__cell_inner) {
padding: 8px !important;
border-radius: 100% !important;
outline: none !important;
border: none !important;
}
.date-picker-wrap :deep(.dp__cell_inner.dp__date_hover:hover) {
background-color: rgba(0, 0, 0, 0.04) !important;
}
.date-picker-wrap :deep(.dp__cell_inner.dp__today) {
border: 1px solid rgba(0, 0, 0, 0.15) !important;
}
.date-picker-wrap :deep(.dp__cell_inner.dp__active_date) {
background-color: #fc4420 !important;
color: #ffffff !important;
}
.date-picker-wrap :deep(.dp__cell_inner.dp__active_date span) {
color: #ffffff !important;
}
.date-picker-wrap :deep(.dp__cell_disabled:hover),
.date-picker-wrap :deep(.dp__cell_disabled) {
cursor: default !important;
background-color: transparent !important;
}
.date-picker-wrap :deep(.dp__menu),
.date-picker-wrap :deep(.dp__menu_wrap),
.date-picker-wrap :deep(.dp__overlay) {
box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.1) !important;
}
.date-picker-wrap :deep(.dp__overlay_cell_pad) {
color: #333333 !important;
}
.date-picker-wrap :deep(.dp__overlay_cell_pad:hover) {
background-color: #f5f5f5 !important;
}
.date-picker-wrap :deep(.dp__overlay_cell_pad.dp__overlay_cell_active) {
background-color: #fc4420 !important;
color: #ffffff !important;
}
.date-picker-wrap :deep(.dp__overlay_cell_pad.dp__overlay_cell_disabled:hover),
.date-picker-wrap :deep(.dp__overlay_cell_pad.dp__overlay_cell_disabled) {
cursor: default !important;
background-color: transparent !important;
opacity: 0.35 !important;
}
.date-picker-wrap :deep(.dp__inner_nav) {
color: #333333 !important;
}
.date-picker-wrap :deep(.dp__inner_nav:hover) {
background-color: transparent !important;
}
.date-picker-wrap :deep(.dp__inner_nav.dp__inner_nav_disabled) {
background-color: transparent !important;
cursor: default !important;
opacity: 0.35 !important;
}
.date-picker-wrap :deep(.dp__month_year_select),
.date-picker-wrap :deep(.dp__month_year_select:hover) {
background-color: transparent !important;
}
.date-picker-wrap :deep(.dp__arrow_bottom),
.date-picker-wrap :deep(.dp__arrow_top),
.date-picker-wrap :deep(.dp__arrow_left),
.date-picker-wrap :deep(.dp__arrow_right) {
display: none !important;
}
.date-picker-wrap.date-picker-menu-absolute :deep(.dp__menu) {
position: absolute !important;
}
.date-picker-wrap.date-picker-menu-fixed :deep(.dp__menu) {
position: fixed !important;
}
.date-picker-wrap :deep(.dp__month_year_select) {
display: none !important;
}
</style>

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
interface Props {
totalCount: number
currentPage: number
pageSize: number
pageBlock?: number
useMinMax?: boolean
}
const props = withDefaults(defineProps<Props>(), {
currentPage: 1,
pageBlock: 10,
useMinMax: true,
})
const emits = defineEmits(['update:page'])
const currentPage = ref(props.currentPage)
const firstPage = ref(1)
const lastPage = computed(() => Math.ceil(props.totalCount / props.pageSize))
const prevPage = computed(() => currentPage.value - 1)
const nextPage = computed(() => currentPage.value + 1)
const currentBlock = computed(() =>
isShortThanBlock.value ? 1 : Math.ceil(currentPage.value / props.pageBlock)
)
// 블럭 크기가 전체 페이지 수보다 작은지 여부
const isShortThanBlock = computed(() => lastPage.value < props.pageBlock)
// 현재 블럭이 첫 블럭인지 여부
const isFirstBlock = computed(() => currentBlock.value === 1)
// 현재 블럭이 마지막 블럭인지 여부
const isLastBlock = computed(
() => currentBlock.value === Math.ceil(lastPage.value / props.pageBlock)
)
const lastBlock = computed(() =>
isShortThanBlock.value ? 1 : Math.ceil(lastPage.value / props.pageBlock)
)
const currentBlockFirstPage = computed(() =>
isShortThanBlock.value ? 1 : (currentBlock.value - 1) * props.pageBlock + 1
)
const blocks = computed(() => {
if (props.totalCount === 0) {
return []
}
const blockSize =
currentBlock.value === lastBlock.value &&
lastPage.value % props.pageBlock !== 0
? lastPage.value - currentBlockFirstPage.value + 1
: props.pageBlock
return Array.from(
{ length: blockSize },
(_, i) => currentBlockFirstPage.value + i
)
})
const handlePagination = (page: number) => {
currentPage.value = page
emits('update:page', page)
}
onMounted(() => {
console.log(blocks.value)
})
</script>
<template>
<div
v-if="props.totalCount > 0"
class="relative flex items-center justify-center w-full py-[32px]"
>
<template v-if="totalCount > pageSize">
<template v-if="props.useMinMax">
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="currentPage === firstPage"
@click="handlePagination(firstPage)"
>
<AtomsIconsArrowDoubleLeftLine
:size="11"
:color="currentPage === firstPage ? '#CCCCCC' : '#333333'"
/>
</button>
</template>
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="currentPage === firstPage"
@click="handlePagination(prevPage)"
>
<AtomsIconsArrowRightLine
:size="16"
:color="currentPage === firstPage ? '#CCCCCC' : '#333333'"
class="rotate-180"
/>
</button>
</template>
<ol class="relative inline-flex items-center justify-center">
<template v-for="page in blocks" :key="page">
<li
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
>
<AtomsButton
type="action"
button-size="size-small"
background-color="transparent"
text-color="#333333"
:class="[
'!w-full !h-full p-0 rounded-full text-center text-[14px] font-[500] leading-[24px] tracking-[-0.42px]',
page === currentPage
? '!bg-[#C7AE8B] !text-white cursor-default'
: '',
]"
@click="handlePagination(page)"
>
<span>{{ page }}</span>
</AtomsButton>
</li>
</template>
</ol>
<template v-if="totalCount > pageSize">
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="currentPage === lastPage"
@click="handlePagination(nextPage)"
>
<AtomsIconsArrowRightLine
:size="16"
:color="currentPage === lastPage ? '#CCCCCC' : '#333333'"
/>
</button>
<button
type="button"
class="relative inline-flex items-center justify-center w-[32px] h-[32px]"
:disabled="currentPage === lastPage"
@click="handlePagination(lastPage)"
>
<AtomsIconsArrowDoubleLeftLine
:size="11"
:color="currentPage === lastPage ? '#CCCCCC' : '#333333'"
class="rotate-180"
/>
</button>
</template>
</div>
</template>
<style scoped></style>

View File

@@ -43,11 +43,11 @@ const componentProps = computed(() => {
<template>
<div class="flex flex-wrap items-end justify-between mb-[16px] md:mb-[24px]">
<h3
class="text-[#1F1F1F] text-[18px] font-bold leading-[26px] tracking-[-0.54px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px]"
class="text-[#1F1F1F] text-[18px] font-bold leading-[26px] tracking-[-0.54px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px] shrink-0"
>
<span>{{ props.title }}</span>
</h3>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between w-full">
<slot />
<p
v-if="props.description && !props.link"

View File

@@ -0,0 +1,175 @@
import type {
ReqGetGuid,
ResGetGuid,
ReqGameCharacterList,
CharacterInfo,
ResGameCharacterList,
} from '#layers/types/api/gameLinkedData'
/**
* 게임 관련 항목
*/
const useGameLinkedData = () => {
const logPrefix = {
exception: '[Exception] /composables/useGameLinkedData',
failure: '[Failure] /composables/useGameLinkedData',
}
const hasGuid = ref(false) // GUID 존재 여부
const characterList = ref([] as CharacterInfo[]) // 보유 캐릭터 목록
const mainCharacter = ref({} as CharacterInfo) // 대표 캐릭터
const selectCharacter = ref(null) // (사용자가 )선택한 캐릭터
// [Setter] GUID 존재 여부 세팅
const setHasGuid = (newHasGuid: boolean) => {
hasGuid.value = newHasGuid
}
// [Setter] 보유 캐릭터 목록 세팅
const setCharacterList = (newCharacters: CharacterInfo[]) => {
characterList.value = newCharacters
}
// [Setter] 대표 캐릭터 세팅
const setMainCharacter = (newCharacter: CharacterInfo) => {
mainCharacter.value = newCharacter
}
// [Setter] 캐릭터 선택 세팅
const setSelectCharacter = (newCharacter: CharacterInfo | null) => {
selectCharacter.value = newCharacter as any
}
// [Computed] 캐릭터 목록 존재 여부
const hasNoCharacterList: ComputedRef<boolean> = computed(() => {
return characterList.value == null || characterList.value.length === 0
})
/**
* GUID 조회
*
* @param {ReqGetGuid} req
* @description https://developers-beta.onstove.com/ko/docs/web/member/api_member_game_id
*/
const getGuid = async (req: ReqGetGuid) => {
let res: ResGetGuid = {} as ResGetGuid
try {
const baseApiUrl = req.baseApiUrl || ''
const url = `${baseApiUrl}/member/v3.0/${req.game_id}`
const headers = {
Authorization: `Bearer ${req.accessToken}`,
}
res = (await commonFetch('GET', url, { headers })) as ResGetGuid
if (res != null) {
if (res.code === 0 && res.value != null && res.value?.guid != null) {
setHasGuid(res.value?.guid > 0)
} else {
res = { code: res.code, message: res.message || '' }
console.log(`${logPrefix.failure}.getGuid: `, res)
res.code = -99999 // else 알럿 띄우기 용 세팅
setHasGuid(false)
}
} else {
console.log(`${logPrefix.failure}.getGuid - res is null: `, res)
res = { code: -99999, message: '' }
setHasGuid(false)
}
} catch (e) {
console.error(`${logPrefix.exception}.getGuid: `, e)
res = { code: -99999, message: `${e}` }
setHasGuid(false)
}
return res
}
/**
* 보유 캐릭터 목록 조회
*
* @param {ReqGameCharacterList} req
* @description https://wiki.smilegate.net/pages/viewpage.action?pageId=193659225
*/
const getCharacterList = async (req: ReqGameCharacterList) => {
let res: ResGameCharacterList = {} as ResGameCharacterList
try {
const baseApiUrl = req.baseApiUrl || ''
const url = `${baseApiUrl}/game/v2.1/${req.game_id}/character`
const headers = {
Authorization: `Bearer ${req.accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
res = (await commonFetch('GET', url, { headers })) as ResGameCharacterList
if (res != null) {
if (res.code === 0) {
const characterInfos =
res.value?.character_infos || ([] as CharacterInfo[])
// '[서버명] 캐릭터명' 포맷의 라벨용 네이밍 세팅
characterInfos.forEach(characterInfo => {
const characterName = `${characterInfo.name}`
let formattedNm = `${characterName}`
// 넘어온 월드 목록이 있는 경우 해당 목록에서 다국어로 된 정보 조회
if (req.world_list != null && req.world_list.length > 0) {
const worldNm =
req.world_list.find(
world => world.world_id === characterInfo?.world_id
)?.world_nm || ''
if (worldNm != null && worldNm !== '') {
formattedNm = `[${worldNm}] ${characterName}`
}
}
characterInfo.formatted_nm = formattedNm
})
setCharacterList(characterInfos)
setMainCharacter(
res.value?.main_game_character || ({} as CharacterInfo)
)
} else if (res.code === 515) {
// [515] AccessToken이 유효하지 않을 경우
res = { code: res.code, message: res.message || '' }
console.log(`${logPrefix.failure}.getCharacterList: `, res)
setCharacterList([] as CharacterInfo[])
setMainCharacter({} as CharacterInfo)
} else {
// [501] 캐릭터가 존재하지 않을 경우(member_no, game_no, character_id 기준)
// [502] 유효하지 않거나 잘못된 파라미터로 호출할 경우
// [701] 존재하지 않은 게임일 경우(game_no 기준)
res = { code: res.code, message: res.message || '' }
console.log(`${logPrefix.failure}.getCharacterList: `, res)
res.code = -99999 // else 알럿 띄우기 용 세팅
setCharacterList([] as CharacterInfo[])
setMainCharacter({} as CharacterInfo)
}
} else {
res = { code: -99999, message: '' }
console.log(`${logPrefix.failure}.getCharacterList: `, res)
setCharacterList([] as CharacterInfo[])
setMainCharacter({} as CharacterInfo)
}
} catch (e) {
console.error(`${logPrefix.exception}.getCharacterList: `, e)
res = { code: -99999, message: `${e}` }
setCharacterList([] as CharacterInfo[])
setMainCharacter({} as CharacterInfo)
}
return res
}
return {
hasGuid,
characterList,
mainCharacter,
selectCharacter,
hasNoCharacterList,
getGuid,
setCharacterList,
setMainCharacter,
setSelectCharacter,
getCharacterList,
}
}
export { useGameLinkedData }

View File

@@ -0,0 +1,222 @@
import {
getUnixTime,
startOfDay,
endOfDay,
differenceInMonths,
} from '@seed-next/date'
import type {
ReqCouponUseParams,
ReqCouponUse,
ResCouponUse,
ResCouponList,
ResCouponListItem,
ReqCouponList,
ReqCouponListParams,
} from '#layers/types/api/couponData'
/**
* 쿠폰 관련 Composable 함수입니다.
* @returns
*/
export const useCoupon = () => {
// Refs
const couponList = ref<Array<ResCouponListItem> | null>([])
const totalCount = ref<number>(0)
const hasNoCouponList = ref<boolean>(false)
/**
* 쿠폰 등록 내역 데이터를 세팅합니다.
* @param {ResCouponList} newValue - 쿠폰 등록 내역 데이터
* @returns
*/
const setCouponList = (newValue: ResCouponList) => {
if (newValue.value.total_count === 0) {
hasNoCouponList.value = true
couponList.value = null
totalCount.value = 0
return
} else {
hasNoCouponList.value = false
couponList.value = newValue.value.list
totalCount.value = newValue.value.total_count
}
}
/**
* 쿠폰 등록 API 호출 함수입니다.
* @param {ReqCouponUse} req - 쿠폰 등록 API 요청 데이터
* @returns {ResCouponUse} res - 쿠폰 등록 API 응답 데이터
*/
const postCouponUse = async (req: ReqCouponUse) => {
let res: ResCouponUse = {} as ResCouponUse
try {
const runtimeConfig = useRuntimeConfig()
const stoveApiUrl = runtimeConfig.public.stoveApiUrl
const userTokenType = req.user_token_type || 'web'
const headers = {
Authorization: `Bearer ${req.accessToken}`,
}
const query: ReqCouponUseParams = {
game_code: req.game_code || '',
coupon_no: req.coupon_no || '',
client_ipaddr: req.client_ipaddr || '',
world_no: req.world_no || '',
character_no: req.character_no || '',
}
const apiUrl = `${stoveApiUrl}/coupon/v3.0/${userTokenType}/coupon/use`
res = (await commonFetch('POST', apiUrl, {
headers,
query,
})) as ResCouponUse
return res
} catch (error) {
console.error('[Exception] useCoupon.postCouponUse:', error)
}
}
/**
* 쿠폰 등록 내역 API 호출 함수입니다.
* @param {ReqCouponList} req - 쿠폰 등록 내역 API 요청 데이터
* @returns {ResCouponList} res - 쿠폰 등록 내역 API 응답 데이터
*/
const getCouponList = async (req: ReqCouponList) => {
let res: ResCouponList = {} as ResCouponList
try {
const runtimeConfig = useRuntimeConfig()
const stoveApiUrl = runtimeConfig.public.stoveApiUrl
const userTokenType = req.user_token_type || 'web'
const headers = {
Authorization: `Bearer ${req.accessToken}`,
}
const query: ReqCouponListParams = {
game_code: req.game_code,
start_date: req.start_date,
end_date: req.end_date,
use_state_code: req.use_state_code,
page_size: req.page_size,
page_no: req.page_no,
lang_code: req.lang_code,
}
const apiUrl = `${stoveApiUrl}/coupon/v3.0/${userTokenType}/couponbox/list`
res = (await commonFetch('GET', apiUrl, {
headers,
query,
})) as ResCouponList
if (res.result === '000') {
setCouponList(res)
} else {
console.error('[Exception] useCoupon.getCouponList:', res.message)
}
} catch (error) {
console.error('[Exception] useCoupon.getCouponList:', error)
}
return res
}
return {
couponList,
totalCount,
hasNoCouponList,
postCouponUse,
getCouponList,
}
}
/**
* 쿠폰 조회 관련 Composable 함수입니다.
* @returns
*/
export const useCouponDate = () => {
// Refs
const startDate = ref<Date | null>(null)
const endDate = ref<Date | null>(null)
const searchStatus = ref<number>(0) // 0: 전체, 1: 사용전, 2: 사용완료/기간만료/사용마감
// Computed
const currentSearchPeriod = computed(() => {
if (!startDate.value || !endDate.value) {
return 0
}
return differenceInMonths(endDate.value, startDate.value)
})
// Setting Functions
const setCouponDate = (newDate: Date, type: 'start' | 'end') => {
if (newDate === null) {
return
}
if (type === 'start') {
startDate.value = newDate
} else {
endDate.value = newDate
}
}
const setSearchStatus = (newStatus: number) => {
searchStatus.value = newStatus
}
// Unix(초) 단위 타임스탬프 변환 (해당 날짜의 시작/종료 시간)
const toUnixTimestamp = (date: Date, type: 'start' | 'end') => {
if (date === null) {
return
}
if (type === 'start') {
return getUnixTime(startOfDay(date))
} else {
return getUnixTime(endOfDay(date))
}
}
return {
startDate,
endDate,
currentSearchPeriod,
searchStatus,
toUnixTimestamp,
setCouponDate,
setSearchStatus,
}
}
/**
* 쿠폰 페이징 관련 Composable 함수입니다.
* @returns
*/
export const useCouponPaging = () => {
const pageNo = ref(1) // 현재 페이지 번호
const pageSize = ref(20) // 페이지 사이즈
const pageBlock = ref(10) // 페이지 블록 사이즈
const updatePagination = (no: number) => {
pageNo.value = no
}
const getPageBlock = (deviceMode: string) => {
return deviceMode === 'desktop' ? pageBlock.value : 5
}
return {
pageNo,
pageSize,
pageBlock,
updatePagination,
getPageBlock,
}
}

View File

@@ -9,8 +9,8 @@ import GrDetail02 from '#layers/templates/GrDetail02/index.vue'
import GrDetail03 from '#layers/templates/GrDetail03/index.vue'
import GrBoard01 from '#layers/templates/GrBoard01/index.vue'
import GrContents01 from '#layers/templates/GrContents01/index.vue'
import FxVideo01 from '#layers/templates/FxVideo01/index.vue'
import FxDownload01 from '#layers/templates/FxDownload01/index.vue'
import FxCoupon01 from '#layers/templates/FxCoupon01/index.vue'
import FxSecure01 from '#layers/templates/FxSecure01/index.vue'
import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue'
@@ -26,8 +26,8 @@ const templateRegistry = {
GR_DETAIL_02: { component: GrDetail02 },
GR_DETAIL_03: { component: GrDetail03 },
GR_CONTENTS_01: { component: GrContents01 },
FX_VIDEO_01: { component: FxVideo01 },
FX_DOWNLOAD_01: { component: FxDownload01 },
FX_COUPON_01: { component: FxCoupon01 },
FX_SECURE_01: { component: FxSecure01 },
FX_PREREGIST_01: { component: FxPreregist01 },
} as const

View File

@@ -0,0 +1,6 @@
import VueDatePicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
export default defineNuxtPlugin(nuxtApp => {
nuxtApp.vueApp.component('VueDatePicker', VueDatePicker)
})

View File

@@ -0,0 +1,111 @@
import type { WorldInfo, CharacterInfo } from '#layers/types/api/gameLinkedData'
export const useCouponStore = defineStore('couponStore', () => {
// Refs
const couponNo = ref('')
const worldList = ref<Array<WorldInfo>>([] as Array<WorldInfo>)
const memberNo = ref<number>(0)
const guid = ref<number>(0)
const selectCharacter = ref(null as CharacterInfo | null)
const nickName = ref('')
const selectWorld = ref({} as WorldInfo)
// Computed
const isSelectCharacter = computed(() => {
return (
selectCharacter.value !== null &&
selectCharacter.value.character_id !== null &&
selectCharacter.value.character_id !== ''
)
})
/**
* 월드 목록 업데이트 함수입니다.
* @param {Array<WorldInfo>} newWorldList - 월드 목록
*/
const updateWorldList = (newWorldList: Array<WorldInfo>) => {
worldList.value = newWorldList
}
/**
* 멤버번호 업데이트 함수입니다.
* @param {number} newMemberNo - 멤버번호
*/
const updateMemberNo = (newMemberNo: number) => {
memberNo.value = newMemberNo
}
/**
* GUID 업데이트 함수입니다.
* @param {number} newGuid - GUID
*/
const updateGuid = (newGuid: number) => {
guid.value = newGuid
}
/**
* 쿠폰 번호 업데이트 함수입니다.
* @param {string} newCouponNo - 쿠폰 번호
*/
const updateCouponNo = (newCouponNo: string) => {
couponNo.value = newCouponNo
}
/**
* 선택한 캐릭터 업데이트 함수입니다.
* @param {CharacterInfo | null} newCharacter - 선택한 캐릭터 (null인 경우 초기화)
*/
const updateSelectCharacter = (newCharacter: CharacterInfo | null) => {
selectCharacter.value = newCharacter
}
/**
* 닉네임 업데이트 함수입니다.
* @param {string} newNickname - 닉네임
*/
const updateNickname = (newNickname: string) => {
nickName.value = newNickname
}
/**
* 선택한 월드 업데이트 함수입니다.
* @param {WorldInfo} newWorld - 선택한 월드
*/
const updateSelectWorld = (newWorld: WorldInfo) => {
selectWorld.value = newWorld
}
/**
* 쿠폰 번호 빈값 여부 검증 함수입니다.
* 빈값인 경우 true, 빈값이 아닌 경우 false를 반환합니다.
* @param {string} value - 쿠폰 번호
* @returns
*/
const isEmptyCouponNo = (value: string) => {
if (value === '') {
return true
} else {
return false
}
}
return {
couponNo,
worldList,
memberNo,
guid,
selectCharacter,
nickName,
selectWorld,
isSelectCharacter,
updateWorldList,
updateMemberNo,
updateGuid,
updateSelectCharacter,
updateNickname,
updateSelectWorld,
updateCouponNo,
isEmptyCouponNo,
}
})

View File

@@ -0,0 +1,855 @@
<script setup lang="ts">
import {
getTime,
fromUnixTime,
addMonths,
differenceInDays,
} from '@seed-next/date'
import { getComponentGroup } from '#layers/utils/dataUtil'
import {
COUPON_NO_LENGTH_LIMIT,
COUPON_RESULT,
} from '#layers/types/api/couponData'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { ReqCouponList } from '#layers/types/api/couponData'
// Props
interface Props {
components: PageDataTemplateComponents
pageVerTmplSeq: number
}
const props = defineProps<Props>()
// Configuration
const runtimeConfig = useRuntimeConfig()
const runType = runtimeConfig.public.runType as string
const staticUrl = runtimeConfig.public.staticUrl as string
const stoveApiUrl = runtimeConfig.public.stoveApiUrl as string
const stoveMaintenanceApiUrl = runtimeConfig.public
.stoveMaintenanceApiUrl as string
const multilingualBaseApiUrl = `${staticUrl}/${runType}/test`
const multilingualFileName = 'test_homepage_brand_coupon.json'
// Multilingual
const resultGetMultilingual = await useGetMultilingual({
baseApiUrl: multilingualBaseApiUrl,
fileName: multilingualFileName,
})
const { t, tm, locale }: any = useI18n({
useScope: 'local',
messages: Object(resultGetMultilingual?.value?.multilingual),
})
// Composables
const { isGameMaintenance, checkGameMaintenance } = useGetGameMaintenance()
const { isWebInspection, getInspectionDataExternal } =
useGetInspectionDataExternal()
const breakpoints = useResponsiveBreakpoints()
const { handleTokenValidation } = useTokenValidation()
const { hasGuid, characterList, getGuid, getCharacterList } =
useGameLinkedData()
const {
couponList,
hasNoCouponList,
totalCount,
postCouponUse,
getCouponList,
} = useCoupon()
const {
startDate,
endDate,
currentSearchPeriod,
searchStatus,
setCouponDate,
toUnixTimestamp,
} = useCouponDate()
const { pageNo, pageSize, pageBlock, updatePagination, getPageBlock } =
useCouponPaging()
// Store
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const couponStore = useCouponStore()
const { gameData } = storeToRefs(gameDataStore)
const { handleOpenAlert, handleOpenConfirm } = modalStore
const { couponNo, isSelectCharacter, selectCharacter } =
storeToRefs(couponStore)
const {
updateMemberNo,
updateCouponNo,
updateSelectCharacter,
isEmptyCouponNo,
} = couponStore
// Data
const backgroundData = computed(() =>
getComponentGroup(props.components, 'background')
)
// Refs
const clientIp = ref('')
const monthSelectList = ref<Array<number>>([1, 3, 6, 12])
const isSelectCharacterModalOpen = ref(false)
// Computed
const minDate = computed(() => {
const date = new Date()
date.setHours(0, 0, 0, 0)
date.setFullYear(date.getFullYear() - 1)
return date
})
const maxDate = computed(() => {
const date = new Date()
date.setHours(23, 59, 59, 999)
return date
})
const sortedCharacterList = computed(() => {
return characterList.value
.map(characterInfo => {
const worldId =
characterInfo.world_id.split('_')[
characterInfo.world_id?.split('_').length - 1
]
const arrWorldIdPriority = ['global', 'asia']
const sortedIndex = arrWorldIdPriority.indexOf(worldId)
return { ...characterInfo, sortedIndex }
})
.sort((a, b) => {
return a.sortedIndex !== b.sortedIndex ? a.sortedIndex - b.sortedIndex : 0
})
})
// Functions
/**
* @description 기본 Alert 모달 팝업 함수입니다.
* @param text - 모달 내용
*/
const openAlert = (text: string) => {
handleOpenAlert({
isShowDimmed: true,
contentText: text,
isOutsideClose: true,
})
}
/**
* @description 유저 IP 조회 함수입니다.
*/
const getClientIp = async () => {
if (!clientIp.value) {
try {
const ipData = await $fetch('/api/clientIp')
clientIp.value = ipData || ''
} catch (e) {
console.error('[Exception] FxCoupon01.getClientIp:', e)
clientIp.value = ''
}
} // 이미 조회했으면 재사용
return clientIp.value
}
/**
* @description 쿠폰 사용 전 유효성 체크 함수입니다.
* @returns {number} 쿠폰 사용 전 유효성 체크 결과
*/
const validationCheckBefore = async () => {
// FIXME: 다른 체크 로직 추가 시 주석 해제 필요 ---------------------------------------------------
/**
* @description 게임 점검 체크하는 로직입니다.
* await checkGameMaintenance({
* baseApiUrl: stoveMaintenanceApiUrl,
* category: 'GAME',
* service_id1: gameData.value.game_id,
* gameId: gameData.value.game_id,
* lang: locale.value,
* })
* if (isGameMaintenance.value) {
* return COUPON_RESULT.GAME_MAINTENANCE
* }
*/
/**
* @description 언어 코드 존재 여부 체크하는 로직입니다.
* if (locale.value == null || locale.value === '') {
* return COUPON_RESULT.SYSTEM_ERROR
* }
*/
/**
* @description 서버(캐릭터) 선택 여부 체크하는 로직입니다.
* if (!isSelectCharacter.value) {
* return COUPON_RESULT.SELECT_CHARACTER_REQUIRED
* }
*/
/**
* @description 선택한 캐릭터 정보 유효 여부 (접속 계정과 동일한 캐릭터 여부) 체크하는 로직입니다.
* await getCharacterList({
* baseApiUrl: stoveApiUrl,
* accessToken: csrGetAccessToken(),
* game_id: gameData.value.game_id,
* gameId: gameData.value.game_id,
* })
* if (
* !characterList.value.some(
* character => character.character_id === selectCharacter.value.character_id
* )
* ) {
* return COUPON_RESULT.SELECT_CHARACTER_REQUIRED
* }
*/
// ------------------------------------------------------------------------
/**
* @description 웹 점검 여부 체크
*/
await getInspectionDataExternal({
baseApiUrl: stoveApiUrl,
gameId: gameData.value.game_id,
})
if (isWebInspection.value) {
return COUPON_RESULT.WEB_INSPECTION
}
/**
* @description 로그인 여부 체크
*/
const accessToken = csrGetAccessToken()
const validateTokenResult = await handleTokenValidation(accessToken || '')
if (validateTokenResult === false) {
return COUPON_RESULT.LOGIN_REQUIRED
}
/**
* @description GUID(게임 이용 동의) 체크
*/
await getGuid({
baseApiUrl: stoveApiUrl,
accessToken: csrGetAccessToken(),
game_id: gameData.value.game_id,
gameId: gameData.value.game_id,
})
if (!hasGuid.value) {
return COUPON_RESULT.EMPTY_GUID
}
/**
* @description 쿠폰번호 입력 여부 체크
*/
if (isEmptyCouponNo(couponNo.value)) {
return COUPON_RESULT.NULL_COUPON_NO
}
/**
* @description 쿠폰번호 유효성 체크 (최소 2자, 최대 20자)
*/
if (
!isInRange(
couponNo.value.length,
COUPON_NO_LENGTH_LIMIT.MIN,
COUPON_NO_LENGTH_LIMIT.MAX
)
) {
return COUPON_RESULT.FAIL_COUPON_FORMAT
}
return 0
}
/**
* @description 쿠폰 등록 버튼 함수입니다.
* @description FE 유효성 체크 후 캐릭터 선택 모달 팝업 노출합니다.
*/
const handleCouponUse = async () => {
const validationCheckBeforeResult = await validationCheckBefore()
if (validationCheckBeforeResult !== 0) {
if (
validationCheckBeforeResult === COUPON_RESULT.SELECT_CHARACTER_REQUIRED
) {
openSelectCharacterModal()
return false
} else if (validationCheckBeforeResult === COUPON_RESULT.SYSTEM_ERROR) {
openAlert(t('Coupon_Error', { code: validationCheckBeforeResult }))
return false
} else {
openAlert(tm(`Coupon_Alert_(${validationCheckBeforeResult})`))
return false
}
} else {
openSelectCharacterModal()
}
}
/**
* @description 캐릭터 선택 후, 확인 버튼 클릭 시 쿠폰 등록 API 호출 함수입니다.
*/
const handleCouponRegister = async () => {
closeSelectCharacterModal()
const validationCheckBeforeResult = await validationCheckBefore()
if (validationCheckBeforeResult !== 0) {
if (
validationCheckBeforeResult === COUPON_RESULT.SELECT_CHARACTER_REQUIRED
) {
openSelectCharacterModal()
return false
} else if (validationCheckBeforeResult === COUPON_RESULT.SYSTEM_ERROR) {
openAlert(t('Coupon_Error', { code: validationCheckBeforeResult }))
return false
} else {
openAlert(tm(`Coupon_Alert_(${validationCheckBeforeResult})`))
return false
}
} else {
await getClientIp()
const res = await postCouponUse({
accessToken: csrGetAccessToken(),
user_token_type: 'web',
game_code: gameData.value.game_code.toString(),
coupon_no: couponNo.value,
client_ipaddr: clientIp.value,
world_no: selectCharacter.value.world_id,
character_no: `${selectCharacter.value.character_id}`,
lang_code: locale.value,
})
if (res.code === 0) {
openAlert(tm('Coupon_Alert_Success'))
} else {
openAlert(tm(`Coupon_Alert_(${res.code})`))
}
}
}
/**
* @description 기간 선택 이벤트 함수입니다.
* @param {number} month - 선택한 기간 개월수
*/
const handlePeriodSelect = async (month: number) => {
const today = new Date()
const startTimestamp = toUnixTimestamp(today, 'start')
const endTimestamp = toUnixTimestamp(today, 'end')
const newStartDate = addMonths(fromUnixTime(startTimestamp), -month)
const newEndDate = fromUnixTime(endTimestamp)
setCouponDate(newStartDate, 'start')
setCouponDate(newEndDate, 'end')
handlePaging(1)
}
/**
* @description 조회하기 이벤트 함수입니다.
*/
const handlePeriodSearch = async () => {
if (!startDate.value || !endDate.value) {
return
}
const accessToken = csrGetAccessToken()
const validateTokenResult = await handleTokenValidation(accessToken || '')
if (validateTokenResult === false) {
return
}
const result = differenceInDays(endDate.value, startDate.value)
if (result > 365) {
openAlert(tm('Coupon_Msg_OverDays'))
return
}
const req: ReqCouponList = {
accessToken: accessToken,
user_token_type: 'web',
game_code: gameData.value.game_code.toString(),
start_date: getTime(startDate.value),
end_date: getTime(endDate.value),
use_state_code: searchStatus.value,
page_size: pageSize.value,
page_no: pageNo.value,
lang_code: locale.value,
}
await getCouponList(req)
}
/**
* @desciprion 페이지 변경 이벤트 함수입니다.
* @param {number} newPageNo
*/
const handlePaging = async (newPageNo: number) => {
updatePagination(newPageNo)
await handlePeriodSearch()
}
/**
* @description 게임별 스토브 쿠폰함 URL 조회 함수입니다.
* @returns {string} 게임별 스토브 쿠폰함 URL
*/
const getCouponBoxUrl = () => {
let url = ''
if (breakpoints.value.isMobile) {
url = getStoveCouponUrl('mobile')
} else {
url = getStoveCouponUrl('desktop')
}
return `${url}?game_id=${gameData.value.game_id}`
}
/**
* @description @coupon{문구} 형식의 문구를 게임별 스토브 쿠폰함 URL의 a태그로 변환합니다.
* @param {string} text - 변환할 문구
* @returns {string} 변환된 문구 HTML
* @description string으로 return하므로 v-dompurify-html 사용 필요
*/
const getKeyToCouponUrl = (text: string) => {
const couponBoxUrl = getCouponBoxUrl()
let result = ''
if (text.includes('@coupon')) {
result = text.replace(/@coupon\{(.*?)\}/g, (_, key) => {
return `<a href="${couponBoxUrl}" rel="noopener noreferrer" target="_blank" class="text-[#3C75FF] underline decoration-solid decoration-auto underline-offset-2 after:content-[''] after:absolute after:top-1/2 after:left-0 after:translate-y-[-50%] after:w-full after:h-fuill after:bg-white after:opacity-0 transition-opacity duration-300 ease-in-out hover:after:opacity-20">${key}</a>`
})
return result
} else {
return text
}
}
/**
* @description STOVE 쿠폰함으로 이동합니다.
*/
const goToCouponBox = () => {
let url = ''
if (breakpoints.value.isMobile) {
url = getStoveCouponUrl('mobile')
} else {
url = getStoveCouponUrl('desktop')
}
csrGoExternalLink(`${url}?game_id=${gameData.value.game_id}`)
}
/**
* @description 쿠폰 등록 내역의 사용하기 버튼의 클릭 이벤트 함수입니다.
*/
const handleGoToCouponBox = async () => {
const accessToken = csrGetAccessToken()
const validateTokenResult = await handleTokenValidation(accessToken || '')
// 미로그인 상태일 경우 Return
if (validateTokenResult === false) {
return
}
// 로그인 상태일 경우 모달 표시
handleOpenConfirm({
contentText: tm('Coupon_Alert_StoveCouponBox'),
confirmButtonText: tm('Coupon_StoveCouponBox'),
modalName: 'modal-coupon-use',
confirmButtonEvent: () => {
goToCouponBox()
},
})
}
/**
* @description 쿠폰 등록 관련 데이터를 초기화합니다.
*/
const initCouponData = () => {
updateSelectCharacter(null)
updateCouponNo('')
updatePagination(1)
}
/**
* @description 캐릭터 선택 모달 팝업 열기
*/
const openSelectCharacterModal = () => {
isSelectCharacterModalOpen.value = true
}
/**
* @description 캐릭터 선택 모달 팝업 닫기
*/
const closeSelectCharacterModal = () => {
isSelectCharacterModalOpen.value = false
}
onMounted(async () => {
initCouponData()
await handlePeriodSelect(1)
})
</script>
<template>
<WidgetsFixMainTitle
:title="tm('Coupon_Page_Title')"
:resourcesData="backgroundData"
/>
<div class="section-container static">
<section class="section-static">
<WidgetsFixSubTitle :title="tm('Coupon_Section_Registration_Title')" />
<div
class="relative flex flex-col items-center jutify-start w-full bg-white p-[20px] mt-[16px] rounded-[12px] sm:rounded-[16px] md:p-[32px] md:mt-[24px]"
>
<div
class="relative flex flex-col items-center justify-start gap-[16px] w-full max-w-[880px] md:gap-[20px]"
>
<div
novalidate
class="relative flex flex-col items-center justify-start gap-[12px] w-full sm:flex-row"
>
<AtomsInput
:model-value="couponNo"
:name="tm('Coupon_Enter_Number')"
:placeholder="tm('Coupon_Enter_Number_Please')"
maxlength="20"
@update:model-value="updateCouponNo"
/>
<AtomsButton
type="action"
button-size="size-small"
background-color="#383838"
text-color="#FFFFFF"
class="relative flex items-center justify-center w-full gap-[4px] px-0 sm:w-[143px] sm:shrink-0 md:h-[56px] md:text-[16px] md:leading-[24px] md:tracking-[-0.48px]"
@click="handleCouponUse"
>
<span>{{ tm('Coupon_Registration') }}</span>
<AtomsIconsCouponFill
:size="breakpoints.isMobile ? 16 : 20"
color="#FFFFFF"
/>
</AtomsButton>
</div>
<ul class="relative flex flex-col items-start justify-start w-full">
<template v-for="notice in tm('Coupon_Notice_List')" :key="notice">
<li
class="relative flex items-start justify-start w-full pl-[20px] before:content-[''] before:absolute before:top-[10px] before:left-[9px] before:w-[3px] before:h-[3px] before:rounded-full before:bg-[#666666] text-left text-[#666666] text-[13px] font-[400] leading-[22px] tracking-[-0.325px] md:text-[14px] md:leading-[24px] md:tracking-[-0.42px] md:before:left-[8px] md:before:w-[4px] md:before:h-[4px]"
>
<span
v-dompurify-html="
getKeyToCouponUrl(tm(notice as string) as string)
"
></span>
</li>
</template>
</ul>
</div>
</div>
</section>
<section class="section-static">
<WidgetsFixSubTitle :title="tm('Coupon_Section_History_Title')">
<div
class="relative flex flex-col items-start justify-start gap-[12px] w-full mt-[16px] md:gap-[16px] md:mt-[24px] lg:flex-row lg:items-end lg:justify-between lg:gap-[0]"
>
<div
class="relative flex items-center justify-start gap-[20px] w-full lg:w-auto lg:mr-auto"
>
<div
v-if="breakpoints.isMd || breakpoints.isDesktop"
class="relative flex items-center justify-start"
>
<template v-for="month in monthSelectList" :key="month">
<AtomsButton
type="action"
button-size="size-small"
background-color="#FAFAFA"
text-color="#CCCCCC"
:class="[
'btn-period',
{ 'btn-period-active': currentSearchPeriod === month },
]"
@click="handlePeriodSelect(month)"
>
<span>{{ tm(`Coupon_Month${month}`) }}</span>
</AtomsButton>
</template>
</div>
<div
class="relative flex items-center justify-center gap-[8px] w-full md:w-auto"
>
<div
class="relative flex items-center justify-center gap-[4px] md:gap-[8px] w-full md:w-auto"
>
<BlocksDatePicker
:key="getTime(startDate)"
:date="startDate"
:min-date="minDate"
:max-date="maxDate"
@update:date="
(date: string) => setCouponDate(new Date(date), 'start')
"
/>
<span
class="relative inline-flex items-center justify-center shrink-0 text-[#868078] text-[16px] font-[500] leading-[24px] tracking-[-0.48px] md:text-[18px] md:leading-[26px] md:tracking-[-0.54px]"
>
~
</span>
<BlocksDatePicker
:key="getTime(endDate)"
:date="endDate"
:min-date="minDate"
:max-date="maxDate"
@update:date="
(date: string) => setCouponDate(new Date(date), 'end')
"
/>
</div>
<AtomsButton
type="action"
button-size="size-small"
background-color="#383838"
text-color="#FFFFFF"
class="shrink-0 w-[40px] h-[40px] p-0 md:w-auto md:px-[22px]"
@click="handlePeriodSearch"
>
<span
class="relative flex items-center justify-center gap-[2px]"
>
<span v-if="breakpoints.isMd || breakpoints.isDesktop">
{{ tm('Coupon_Search') }}
</span>
<AtomsIconsSearchLine :size="16" color="#FFFFFF" />
</span>
</AtomsButton>
</div>
</div>
<p
class="text-[#666666] text-[13px] font-[400] leading-[22px] tracking-[-0.325px] md:text-[14px] md:leading-[24px] md:tracking-[-0.421px]"
>
{{ tm('Coupon_Section_History_Description') }}
</p>
</div>
</WidgetsFixSubTitle>
<div
class="relative w-full border border-solid border-[#D9D9D9] rounded-[12px] overflow-hidden sm:rounded-[16px]"
>
<table>
<thead>
<tr>
<th class="!border-l-[0]">
{{ tm('Coupon_Item_Name') }}
</th>
<th
v-if="breakpoints.isMd || breakpoints.isDesktop"
class="w-[260px]"
>
{{ tm('Coupon_Item_RegistDate') }}
</th>
<th class="w-[90px] md:w-[180px]">
{{ tm('Coupon_Item_Status') }}
</th>
</tr>
</thead>
<tbody>
<template v-if="hasNoCouponList">
<tr>
<td
:colspan="
breakpoints.isMd || breakpoints.isDesktop ? '3' : '2'
"
class="!border-l-[0] !border-b-[0]"
>
<p>{{ tm('Coupon_Has_Not_Item') }}</p>
</td>
</tr>
</template>
<template v-else>
<template
v-for="(coupon, couponIdx) in couponList"
:key="coupon.coupon_box_id"
>
<tr>
<td
:class="
couponIdx === couponList.length - 1
? '!border-l-[0] !border-b-[0]'
: '!border-l-[0]'
"
>
<p>{{ coupon.coupon_name }}</p>
</td>
<td
v-if="breakpoints.isMd || breakpoints.isDesktop"
:class="
couponIdx === couponList.length - 1 ? '!border-b-[0]' : ''
"
>
<p>{{ coupon.register_date }}</p>
</td>
<td
:class="
couponIdx === couponList.length - 1 ? '!border-b-[0]' : ''
"
>
<template
v-if="
coupon.use_state === 1 &&
coupon.reward_type_code.includes(2)
"
>
<AtomsButton
type="action"
button-size="size-small"
background-color="transparent"
text-color="transparent"
class="coupon-item coupon-item-use"
@click="handleGoToCouponBox"
>
<span>{{ tm('Coupon_Item_Use') }}</span>
</AtomsButton>
</template>
<template v-else-if="coupon.use_state === 1">
<span class="coupon-item coupon-item-not-used">
{{ tm('Coupon_Item_Not_Used') }}
</span>
</template>
<template v-else-if="coupon.use_state === 2">
<span class="coupon-item coupon-item-used">
{{ tm('Coupon_Item_Used') }}
</span>
</template>
<template v-else-if="coupon.use_state === 4">
<span class="coupon-item coupon-item-expires">
{{ tm('Coupon_Item_Expires') }}
</span>
</template>
<template v-else-if="coupon.use_state === 6">
<span class="coupon-item coupon-item-deadline">
{{ tm('Coupon_Item_Deadline') }}
</span>
</template>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
<BlocksPagination
:key="pageNo"
:current-page="pageNo"
:page-size="pageSize"
:page-block="getPageBlock(breakpoints.isMobile ? 'mobile' : 'desktop')"
:total-count="totalCount"
:use-min-max="true"
@update:page="handlePaging"
/>
</section>
</div>
<ClientOnly>
<Teleport to="body">
<BlocksModalLayer
:is-show-dimmed="true"
:is-outside-close="true"
modal-name="modal-coupon-character-select"
:is-open="isSelectCharacterModalOpen"
area-class="max-w-[480px] p-6 bg-white rounded-[20px]"
close-class="hidden"
>
<div
class="relative flex flex-col items-center justify-center gap-[24px] w-full"
>
<p
class="relative flex items-start justify-center w-full text-center text-[#333333] text-[15px] font-[400] leading-[24px] tracking-[-0.45px]"
v-dompurify-html="tm('Coupon_Alert_SelectCharacter')"
></p>
<AtomsSelect
:options="sortedCharacterList"
label-name="formatted_nm"
:placeholder="tm('Coupon_Alert_EnterCharacter')"
@update:model-value="updateSelectCharacter"
/>
<div
class="relative flex items-center justify-center gap-[8px] w-full"
>
<AtomsButtonVariant
variant="outlined"
class="max-w-[128px]"
@click="closeSelectCharacterModal"
>
{{ tm('Text_Cancel') }}
</AtomsButtonVariant>
<AtomsButtonVariant
:disabled="isSelectCharacter ? false : true"
class="max-w-[128px]"
@click="handleCouponRegister"
>
{{ tm('Text_Confirm') }}
</AtomsButtonVariant>
</div>
</div>
</BlocksModalLayer>
</Teleport>
</ClientOnly>
</template>
<style scoped>
/* Button Style */
.btn-period {
@apply border border-solid border-[#D9D9D9] rounded-[0] w-auto h-[40px] px-[16px] before:content-none after:content-none
transition-all duration-300 ease-in-out
hover:z-[4] hover:!bg-[#FFFFFF] hover:border-[#404040] hover:!text-[#1F1F1F];
}
.btn-period + .btn-period {
@apply ml-[-1px];
}
.btn-period:first-child {
@apply rounded-l-[8px];
}
.btn-period:last-child {
@apply rounded-r-[8px];
}
.btn-period.btn-period-active {
@apply z-[4] !bg-[#FFFFFF] !border-[#404040] !text-[#1F1F1F];
}
/* Table Style */
table {
@apply w-full h-auto border-collapse border-spacing-0 table-fixed;
}
table th {
@apply py-[8px] px-[12px] border border-[#D9D9D9] border-t-[0] border-r-[0] bg-[#FAFAFA] text-[#1F1F1F] text-[14px] font-bold leading-[24px] tracking-[-0.42px]
md:py-[11px] md:px-[20px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px];
}
table td {
@apply h-[auto] p-[12px] border border-[#D9D9D9] border-t-[0] border-r-[0] bg-[#FFFFFF] text-center text-[#666666] text-[14px] font-[400] leading-[24px] tracking-[-0.42px]
md:px-[20px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px];
}
table td p {
@apply line-clamp-2 text-center;
}
/* Coupon Item Style */
.coupon-item {
@apply relative inline-flex items-center justify-center !w-auto !h-auto py-[2px] px-[6px] border-none rounded-full !bg-[#EBEBEB] text-center !text-[#999999] text-[12px] font-[500] leading-[18px] tracking-[-0.24px]
md:py-[4px] md:px-[8px] md:text-[14px] md:leading-[20px] md:tracking-[-0.42px];
}
.coupon-item.coupon-item-use {
@apply !bg-[#383838] !text-white;
}
.coupon-item.coupon-item-used {
@apply !bg-[#E2EAFF] !text-[#3C75FF];
}
.coupon-item.coupon-item-not-used {
@apply !bg-[#EEF6F1] !text-[#2B9450];
}
</style>

View File

@@ -315,18 +315,7 @@ const handleMoveFocus = (target: 'pc' | 'mobile') => {
<span
class="relative inline-flex items-center justify-center w-[20px] h-[20px]"
>
<img
:src="
getImageHost(
'/images/common/ic-v2-hardware-desktop-line.svg',
{ imageType: 'common' }
)
"
alt="Desktop-Icon"
loading="lazy"
draggable="false"
class="w-full object-contain"
/>
<AtomsIconsDesktopLine :size="16" color="#FFFFFF" />
</span>
<span>{{ tm('Download_Button_SpecCheck') }}</span>
</em>

View File

@@ -0,0 +1,104 @@
const COUPON_NO_LENGTH_LIMIT = {
MIN: 2,
MAX: 20,
}
const COUPON_RESULT = {
LOGIN_REQUIRED: 401, // 로그인 필요
EMPTY_GUID: 6063, // 게임 이용 약관 미동의
GAME_MAINTENANCE: 6065, // 게임 점검 중
SELECT_CHARACTER_REQUIRED: 7014, // 캐릭터 미선택
WEB_INSPECTION: -90003, // 웹 점검 중
NULL_SERVER: -90004, // 서버(월드) 미선택
NULL_MEMBERNO: -90005, // 스토브 회원 번호 미입력
NULL_NICKNAME: -90006, // 캐릭터 닉네임 미입력
NULL_COUPON_NO: -90007, // 쿠폰 코드 미입력
FAIL_COUPON_FORMAT: -90008, // 자릿수 잘못된 쿠폰 코드
CHARACTER_MATCH_FAIL: -90009, // 캐릭터 정보가 존재하나 접속 계정의 캐릭터 정보와 불일치
SYSTEM_ERROR: 999, // 시스템 오류
}
interface ReqCouponUseParams {
game_code: string
coupon_no: string
client_ipaddr: string
world_no?: string
character_no?: string
game_coupon_id?: number
cbox_save_flag?: boolean
lang_code?: string
}
interface ReqCouponUse extends ReqCouponUseParams {
accessToken: string
user_token_type?: string | 'web' | 'ingame' | 'guid'
}
interface ResCouponUseValue {
coupon_box_id: number
cbox_save_flag: boolean
}
interface ResCouponUse {
code?: number
result?: string
message?: string
value?: ResCouponUseValue
}
interface MyCharacterReqType {}
interface MyCharacterResType {}
interface ReqCouponListParams {
game_code: string
start_date: number
end_date: number
use_state_code?: number
page_size: number
page_no: number
lang_code?: string
}
interface ReqCouponList extends ReqCouponListParams {
accessToken: string
user_token_type?: string | 'web' | 'ingame' | 'guid'
}
interface ResCouponListItem {
coupon_box_id: number
game_code: string
coupon_name: string
reward_type_code: Array<number>
reward_type: Array<string>
use_start_date: number
use_end_date: number
register_date: number
use_date: number
use_state: number
}
interface ResCouponList {
code?: number
result?: string
message?: string
value?: {
total_count: number
list: Array<ResCouponListItem>
}
}
export { COUPON_NO_LENGTH_LIMIT, COUPON_RESULT }
export type {
ReqCouponUseParams,
ReqCouponUse,
ResCouponUseValue,
ResCouponUse,
MyCharacterReqType,
MyCharacterResType,
ReqCouponListParams,
ReqCouponList,
ResCouponListItem,
ResCouponList,
}

View File

@@ -0,0 +1,90 @@
import type {
CommonRequestType,
CommonResponseType,
} from '#layers/types/Common'
/*************************************************************************
* 스토브 회원
************************************************************************/
/**
* GUID 조회
*/
interface ReqGetGuid extends CommonRequestType {
// Header
accessToken: string // 엑세스 토큰
// Path Variables
game_id: string // 게임ID
}
interface DtoGuid {
member_no: number // 회원번호
guid: number // GUID
}
interface ResGetGuid extends CommonResponseType {
value?: DtoGuid
}
/*************************************************************************
* 월드 & 캐릭터
************************************************************************/
/**
* 월드 목록 조회
*/
interface WorldInfo {
world_id: string
world_nm: string
}
/**
* 보유 캐릭터 목록 조회
*/
interface ReqGameCharacterList extends CommonRequestType {
// Path Variables
game_id: string
// Header
accessToken: string
// Custom
world_list?: WorldInfo[]
}
interface CharacterInfo {
character_id: string
world_id: string
world_nm?: string // 서버명
character_seq?: number
name: string // E7 : 캐릭터명, L9 : [서버명]캐릭터명
character_info?: string
profile_image_url?: string
is_main_character: string // Y/N
level?: string
reg_dt?: number
upd_dt?: number
exp?: number
// Custom
formatted_nm?: string // [서버명] 캐릭터명
}
interface DtoGameCharacterList {
id: string
game_no: number
game_id: string
member_no: number
nickname_flag: string
reg_dt: number
upd_dt: number
character_infos: Array<CharacterInfo>
main_game_character: CharacterInfo
}
interface ResGameCharacterList extends CommonResponseType {
value?: DtoGameCharacterList
}
export type {
ReqGetGuid,
ResGetGuid,
WorldInfo,
ReqGameCharacterList,
CharacterInfo,
ResGameCharacterList,
}

View File

@@ -82,3 +82,15 @@ export const csrGetCountry = () => {
}
return countryCode
}
/**
* STOVE 쿠폰함 링크 조회
*/
export const getStoveCouponUrl = (device: string) => {
const runtimeConfig = useRuntimeConfig()
const stoveCouponUrl = runtimeConfig.public.stoveCouponUrl as string
const stoveMCouponUrl = runtimeConfig.public.stoveMCouponUrl as string
return device === 'desktop' ? stoveCouponUrl : stoveMCouponUrl
}

View File

@@ -88,6 +88,9 @@ export default defineNuxtConfig({
stoveGnb: process.env.STOVE_GNB,
stoveCouponUrl: process.env.STOVE_COUPON_URL,
stoveMCouponUrl: process.env.STOVE_M_COUPON_URL,
stoveCs: process.env.STOVE_CS,
stoveLauncherScript: process.env.STOVE_LAUNCHER_SCRIPT,

View File

@@ -32,6 +32,7 @@
"@seed-next/date": "^0.0.0",
"@splidejs/splide": "^4.1.4",
"@splidejs/vue-splide": "^0.6.12",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^13.6.0",
"@vueuse/nuxt": "^13.6.0",
"h3": "^1.15.4",

14
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
'@splidejs/vue-splide':
specifier: ^0.6.12
version: 0.6.12
'@vuepic/vue-datepicker':
specifier: ^11.0.2
version: 11.0.3(vue@3.5.21(typescript@5.9.2))
'@vueuse/core':
specifier: ^13.6.0
version: 13.9.0(vue@3.5.21(typescript@5.9.2))
@@ -1872,6 +1875,12 @@ packages:
'@vue/shared@3.5.21':
resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==}
'@vuepic/vue-datepicker@11.0.3':
resolution: {integrity: sha512-sb2adwqwK2PizLQOpxCYps2SwhVT6/ic2HMIOqHJXuYa6iAJZWGL5YVlS7O4aW+sk6ZyxlDURLO7kDZPL4HB/w==}
engines: {node: '>=18.12.0'}
peerDependencies:
vue: '>=3.3.0'
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
@@ -6741,6 +6750,11 @@ snapshots:
'@vue/shared@3.5.21': {}
'@vuepic/vue-datepicker@11.0.3(vue@3.5.21(typescript@5.9.2))':
dependencies:
date-fns: 4.1.0
vue: 3.5.21(typescript@5.9.2)
'@vueuse/core@13.9.0(vue@3.5.21(typescript@5.9.2))':
dependencies:
'@types/web-bluetooth': 0.0.21

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B