Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: barcode detector #1639

Open
wants to merge 43 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
604a5b9
feat: add lib and button
NaMax66 Mar 13, 2025
62ec9c2
feat: generate components
NaMax66 Mar 13, 2025
7802c69
fix: test for mobile
NaMax66 Mar 14, 2025
64a0382
feat: add button to mobile
NaMax66 Mar 14, 2025
c3f2aaf
fix type
NaMax66 Mar 14, 2025
aeb5732
fix: scanner
NaMax66 Mar 17, 2025
4fa2e6f
fix: refactor
NaMax66 Mar 17, 2025
bc8a2f4
fix: search
NaMax66 Mar 17, 2025
f3c54ef
fix: change barcode icon
NaMax66 Mar 18, 2025
4b73e0d
fix: change barcode icon desktop
NaMax66 Mar 18, 2025
e6f9aae
feat: add locales
NaMax66 Mar 18, 2025
33c3fcd
feat: add skeleton
NaMax66 Mar 18, 2025
e8b3b83
Merge remote-tracking branch 'origin/dev' into feat/vcst-2622-barcode…
NaMax66 Mar 19, 2025
8a3cd7d
fix: align with design
NaMax66 Mar 19, 2025
8254243
fix: add redirect search
NaMax66 Mar 19, 2025
4003201
feat: add external support
NaMax66 Mar 19, 2025
115ee12
fix: remove settings
NaMax66 Mar 19, 2025
dd07fc6
fix: remove src
NaMax66 Mar 19, 2025
a0b68ac
fix: bem
NaMax66 Mar 19, 2025
da74e51
fix: bem
NaMax66 Mar 20, 2025
9969abc
fix: review
NaMax66 Mar 20, 2025
e051cdb
fix: review
NaMax66 Mar 20, 2025
4653968
fix: append
NaMax66 Mar 20, 2025
d3b1394
fix: w-full
NaMax66 Mar 20, 2025
4136d10
Merge branch 'dev' into feat/vcst-2622-barcode-detector
NaMax66 Mar 21, 2025
8717250
feat: add file selection
NaMax66 Mar 21, 2025
178ed8c
feat: add files support
NaMax66 Mar 21, 2025
7f65d22
feat: fix input
NaMax66 Mar 21, 2025
9f932c3
feat: remove attrs
NaMax66 Mar 21, 2025
3a368a5
Merge remote-tracking branch 'origin/dev' into feat/vcst-2622-barcode…
NaMax66 Mar 24, 2025
943bcbb
feat: add on cancel
NaMax66 Mar 24, 2025
12ac9fb
feat: add error handle
NaMax66 Mar 24, 2025
0915a70
feat: remove host
NaMax66 Mar 24, 2025
06b4930
Merge remote-tracking branch 'origin/dev' into feat/vcst-2622-barcode…
NaMax66 Mar 25, 2025
fd74137
feat: install yarn
NaMax66 Mar 25, 2025
ee15d7f
Merge remote-tracking branch 'origin/dev' into feat/vcst-2622-barcode…
NaMax66 Mar 26, 2025
1aef256
chore: add locales
NaMax66 Mar 26, 2025
ee870bf
chore: add locales
NaMax66 Mar 26, 2025
7a660d8
fix: add locales
NaMax66 Mar 26, 2025
79261f3
fix: change buttons
NaMax66 Mar 26, 2025
0ddb824
feat: simplify
NaMax66 Mar 26, 2025
e0ab720
Merge remote-tracking branch 'origin/dev' into feat/vcst-2622-barcode…
NaMax66 Mar 27, 2025
58fb7d8
feat: review
NaMax66 Mar 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions client-app/router/routes/constants.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
type RouteType = {
NAME: string;
PATH: string;
};

export const ROUTES: { [key: string]: RouteType } = {
export const ROUTES = {
CATALOG: {
NAME: "Catalog",
PATH: "/catalog",
},
SEARCH: {
NAME: "Search",
PATH: "/search",
},
} as const;
2 changes: 1 addition & 1 deletion client-app/router/routes/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const mainRoutes: RouteRecordRaw[] = [
},
},
{ path: "/branch/:branchId", name: "BranchPage", component: Branch, props: true },
{ path: "/search", name: "Search", component: Search },
{ path: ROUTES.SEARCH.PATH, name: ROUTES.SEARCH.NAME, component: Search },
{ path: "/bulk-order", name: "BulkOrder", component: BulkOrder },
{ path: "/compare", name: "CompareProducts", component: CompareProducts },
{ path: "/cart", name: "Cart", component: Cart },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,15 @@
maxlength="64"
:placeholder="$t('shared.layout.header.mobile.search_bar.input_placeholder')"
class="mr-4 grow"
:clearable="!!searchPhrase"
no-border
clearable
@keydown.enter="searchPhrase && $router.push(searchPageLink)"
@clear="reset"
/>
@keydown.enter="searchPhrase && $router.push(searchPageLink)"
>
<template #append>
<BarcodeScanner v-if="!searchPhrase" @scanned-code="onBarcodeScanned" />
</template>
</VcInput>

<VcButton :to="searchPhrase && searchPageLink" icon="search" />

Expand Down Expand Up @@ -137,6 +141,7 @@ import { useSearchBar } from "@/shared/layout/composables/useSearchBar";
import MobileMenu from "./mobile-menu/mobile-menu.vue";
import type { StyleValue } from "vue";
import type { RouteLocationRaw } from "vue-router";
import BarcodeScanner from "@/shared/layout/components/search-bar/barcode-scanner.vue";
const router = useRouter();

const { customComponents } = useCustomMobileHeaderComponents();
Expand All @@ -158,15 +163,22 @@ const placeholderStyle = computed<StyleValue | undefined>(() =>
);

const searchPageLink = computed<RouteLocationRaw>(() => ({
name: "Search",
name: ROUTES.SEARCH.NAME,
query: {
[QueryParamName.SearchPhrase]: searchPhrase.value.trim(),
},
}));

function reset() {
searchPhrase.value = "";
void router.push({ name: ROUTES.CATALOG.NAME });
void router.push({ name: ROUTES.SEARCH.NAME });
}

function onBarcodeScanned(value: string) {
if (value) {
searchPhrase.value = value;
void router.push(searchPageLink.value);
}
}

syncRefs(mobileMenuVisible, useScrollLock(document.body));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<template>
<VcModal
:title="$t('shared.layout.search_bar.barcode_detector.title')"
class="barcode-scanner-modal"
is-mobile-fullscreen
>
<p class="barcode-scanner-modal__description">
{{ $t("shared.layout.search_bar.barcode_detector.description") }}
</p>

<VcWidgetSkeleton v-show="loading">
<template #default-container>
<div class="barcode-scanner-modal__skeleton">
<div class="barcode-scanner-modal__skeleton-inner" />
</div>
</template>
</VcWidgetSkeleton>

<video
v-show="!loading"
ref="videoElement"
class="barcode-scanner-modal__video"
playsinline
@canplaythrough="loading = false"
>
<track kind="captions" :label="$t('shared.layout.search_bar.barcode_detector.title')" default disabled src="" />
</video>
<input
ref="fileInputRef"
type="file"
class="barcode-scanner-modal__input"
accept="image/*"
@change="onFileSelected"
/>

<template #actions="{ close }">
<VcButton class="barcode-scanner-modal__action-cancel" color="secondary" variant="outline" @click="close">
{{ $t("shared.catalog.branches_modal.cancel_button") }}
</VcButton>
<VcButton
class="barcode-scanner-modal__action-browse"
color="secondary"
:loading="loading"
@click="openFilePicker"
>{{ $t("ui_kit.file_uploader.browse") }}</VcButton
>
</template>
</VcModal>
</template>

<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { BarcodeDetector } from "barcode-detector/ponyfill";
import { ref, onMounted, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import { Logger } from "@/core/utilities";
import { useNotifications } from "@/shared/notification";

const emit = defineEmits<{
(e: "result", value: string[]): void;
}>();

const videoElement = ref<HTMLVideoElement | null>(null);
const videoStream = ref<MediaStream | null>(null);
const { t } = useI18n();

let barcodeDetector: BarcodeDetector | null = null;

const loading = ref(true);

const SCAN_INTERVAL = 400;

const notifications = useNotifications();

const fileInputRef = ref<HTMLInputElement>();

function openFilePicker() {
fileInputRef.value?.click();
}

async function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];

try {
if (!file) {
showInfo(t("shared.layout.search_bar.barcode_detector.errors.no_file"));
return;
}

if (!file.type.startsWith("image/")) {
showInfo(t("shared.layout.search_bar.barcode_detector.errors.wrong_format"));
return;
}

loading.value = true;

const barcodes = await barcodeDetector?.detect(file);

if (barcodes?.length) {
emitResult(barcodes);
} else {
showInfo(t("shared.layout.search_bar.barcode_detector.errors.no_barcode_detected"));
}

loading.value = false;
} catch (e) {
Logger.error(onFileSelected.name, e);
}
}

async function scan() {
if (!barcodeDetector || !videoElement.value) {
return;
}
try {
const imageBitmap = await createImageBitmap(videoElement.value);

const barcodes = await barcodeDetector?.detect(imageBitmap);
if (barcodes?.length) {
emitResult(barcodes);
}
} catch (error) {
Logger.error(scan.name, error);
}
}

const { resume: startScanner, pause: stopScanner } = useIntervalFn(scan, SCAN_INTERVAL);

async function startCamera() {
try {
if (!videoStream.value) {
videoStream.value = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
}

if (videoElement.value) {
videoElement.value.srcObject = videoStream.value;
await videoElement.value.play();
}
} catch (error) {
Logger.error(startCamera.name, error);
}
}

function stopCamera() {
if (videoStream.value) {
videoStream.value.getVideoTracks().forEach((track) => track.stop());
videoStream.value = null;
}

if (videoElement.value) {
videoElement.value.srcObject = null;
}
}

function showInfo(text: string) {
notifications.info({
text,
duration: 5000,
single: true,
});
}

function emitResult(barcodes: { rawValue: string }[]) {
emit(
"result",
barcodes.map((el) => el.rawValue),
);
}

onMounted(async () => {
try {
await startCamera();
barcodeDetector = new BarcodeDetector();
startScanner();
} catch (error) {
Logger.error("BarcodeDetector initialization error:", error);
}
});

onBeforeUnmount(() => {
stopScanner();
barcodeDetector = null;
stopCamera();
});
</script>

<style lang="scss">
.barcode-scanner-modal {
&__description {
@apply mb-2.5;
}

&__video {
@apply aspect-[3/2] object-cover md:aspect-[2/1];
}

&__skeleton {
@apply p-1;
}

&__skeleton-inner {
@apply h-60 w-full #{!important};
}

&__input {
@apply hidden;
}
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<template>
<VcButton
class="barcode-scanner"
color="primary"
variant="no-border"
size="xs"
type="button"
icon="barcode"
@click.stop="openBarcodeScanner"
/>
</template>

<script setup lang="ts">
import { useModal } from "@/shared/modal";
import BarcodeScannerModal from "./barcode-scanner-modal.vue";
const emit = defineEmits<IEmits>();

interface IEmits {
(e: "scannedCode", code: string): void;
}
const { openModal, closeModal } = useModal();

const onBarcodeValueReceived = (codes: string[]) => {
if (codes.length) {
emit("scannedCode", codes[0]);
closeModal();
}
};

const openBarcodeScanner = () => {
openModal({
component: BarcodeScannerModal,
props: {
onClose: closeModal,
onResult: onBarcodeValueReceived,
},
});
};
</script>

<style lang="scss">
.barcode-scanner {
@apply m-1.5;
}
</style>
19 changes: 15 additions & 4 deletions client-app/shared/layout/components/search-bar/search-bar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
type="search"
:maxlength="MAX_LENGTH"
class="w-full"
:clearable="!!searchPhrase"
:placeholder="$t('shared.layout.search_bar.enter_keyword_placeholder')"
clearable
@clear="reset"
@keyup.enter="goToSearchResultsPage"
@keyup.esc="hideSearchDropdown"
@input="onSearchPhraseChanged"
@focus="onSearchBarFocused"
@clear="reset"
>
<template #append>
<BarcodeScanner v-if="!searchPhrase" @scanned-code="onBarcodeScanned" />

<VcButton
:aria-label="$t('shared.layout.search_bar.search_button')"
icon="search"
Expand Down Expand Up @@ -146,10 +148,12 @@ import { getFilterExpressionForCategorySubtree, getFilterExpressionForZeroPrice
import { ROUTES } from "@/router/routes/constants";
import { useSearchBar } from "@/shared/layout/composables/useSearchBar";
import SearchBarProductCard from "./_internal/search-bar-product-card.vue";
import BarcodeScanner from "./barcode-scanner.vue";
import type { GetSearchResultsParamsType } from "@/core/api/graphql/catalog";
import type { Category } from "@/core/api/graphql/types";
import type { StyleValue } from "vue";
import type { RouteLocationRaw } from "vue-router";
import VcButton from "@/ui-kit/components/molecules/button/vc-button.vue";

const { themeContext } = useThemeContext();

Expand Down Expand Up @@ -272,7 +276,7 @@ async function searchAndShowDropdownResults(): Promise<void> {

function getSearchRoute(phrase: string): RouteLocationRaw {
return {
name: "Search",
name: ROUTES.SEARCH.NAME,
query: {
[QueryParamName.SearchPhrase]: phrase,
},
Expand All @@ -290,7 +294,7 @@ function goToSearchResultsPage() {
function reset() {
searchPhrase.value = "";
hideSearchDropdown();
void router.push({ name: ROUTES.CATALOG.NAME });
void router.push({ name: ROUTES.SEARCH.NAME });
}

const searchProductsDebounced = useDebounceFn(searchAndShowDropdownResults, SEARCH_BAR_DEBOUNCE_TIME);
Expand Down Expand Up @@ -320,4 +324,11 @@ onMounted(() => {
searchPhrase.value = searchPhraseInUrl.value;
}
});

const onBarcodeScanned = (value: string) => {
if (value) {
searchPhrase.value = value;
goToSearchResultsPage();
}
};
</script>
Loading
Loading