@raxonltd/raxon-core 1.1.7 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/component/general.image.tsx +86 -0
- package/core/context/cart.context.tsx +446 -0
- package/core/context/security.context.tsx +151 -0
- package/core/feature/address/api/places.api.ts +76 -0
- package/core/feature/address/form/address-search-input.tsx +125 -0
- package/core/feature/address/hook/use.addres.tsx +63 -0
- package/core/feature/address/hook/use.address-autocomplete.ts +116 -0
- package/core/feature/address/util/address.types.ts +38 -0
- package/core/feature/address/util/parse-google-place.ts +66 -0
- package/core/feature/analytic-event/analytic.event.api.ts +27 -0
- package/core/feature/analytic-event/analytic.event.context.tsx +180 -0
- package/core/feature/analytic-event/analytic.event.util.ts +42 -0
- package/core/feature/analytic-event/use.analytic.auto.tsx +114 -0
- package/core/feature/article/hook/use.article.tsx +33 -0
- package/core/feature/attribute/hook/use.attribute.tsx +24 -0
- package/core/feature/auth/hook/use.auth.tsx +141 -0
- package/core/feature/auth/modal/modal.auth.tsx +80 -0
- package/core/feature/auth/view/view.login.tsx +199 -0
- package/core/feature/auth/view/view.register.tsx +333 -0
- package/core/feature/bank-account/hook/use.bank.account.tsx +47 -0
- package/core/feature/brand/hook/use.brand.tsx +24 -0
- package/core/feature/cart/component/cart.order.summary.tsx +89 -0
- package/core/feature/cart/component/cart.promo.code.section.tsx +208 -0
- package/core/feature/cart/hook/use.cart.tsx +267 -0
- package/core/feature/cart/util/basket-pay.response.ts +67 -0
- package/core/feature/cart/util/cart-optimistic.ts +425 -0
- package/core/feature/cart/util/garanti-payment.ts +27 -0
- package/core/feature/collection/hook/use.collection.tsx +32 -0
- package/core/feature/delivery-method/hook/use.delivery.method.tsx +40 -0
- package/core/feature/delivery-method/util/checkout.delivery.method.ts +11 -0
- package/core/feature/faq/hook/use.faq.tsx +23 -0
- package/core/feature/favorite/hook/use.favorite.tsx +48 -0
- package/core/feature/form-submit/form/form.contact.tsx +118 -0
- package/core/feature/form-submit/hook/use.form.submit.tsx +16 -0
- package/core/feature/invoice/hook/use.invoice.tsx +51 -0
- package/core/feature/newsletter/hook/use.newsletter.tsx +124 -0
- package/core/feature/newsletter/modal/modal.newsletter.product.tsx +163 -0
- package/core/feature/order/hook/use.order.tsx +31 -0
- package/core/feature/payment-method/checkout.payment.options.ts +117 -0
- package/core/feature/payment-method/hook/use.payment.method.tsx +44 -0
- package/core/feature/product/hook/use.product.tsx +122 -0
- package/core/feature/profile/hook/use.profile.tsx +126 -0
- package/core/feature/promo-code/hook/use.promo.code.tsx +27 -0
- package/core/interface/basket.interface.ts +360 -0
- package/core/interface/bootstrap.interface.ts +39 -0
- package/core/interface/context.interface.ts +9 -0
- package/core/interface/inventory.interface.ts +88 -0
- package/core/interface/nexine.interface.ts +4 -0
- package/core/interface/prisma.interface.ts +8844 -0
- package/core/interface/product.interface.ts +111 -0
- package/core/raxon.context.tsx +256 -0
- package/core/schema/checkout.schema.ts +103 -0
- package/core/util/basket.item.display.ts +19 -0
- package/core/util/category.nav.ts +46 -0
- package/core/util/client-ip.ts +35 -0
- package/core/util/collection.util.ts +433 -0
- package/core/util/fetch.bootstrap.ts +21 -0
- package/core/util/garanti-payment.ts +5 -0
- package/core/util/nexine.axios.tsx +104 -0
- package/core/util/no-cache.ts +6 -0
- package/core/util/util.ts +191 -0
- package/core/view/view.checkout.tsx +1964 -0
- package/dist/core/view/view.checkout.js +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -3
- package/tailwind.css +11 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { GooglePlaceDetails, GooglePlacePrediction } from '@/core/feature/address/util/address.types';
|
|
2
|
+
|
|
3
|
+
function getGoogleMapsApiKey(): string {
|
|
4
|
+
return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface PlacesAutocompleteOptions {
|
|
8
|
+
components?: string;
|
|
9
|
+
language?: string;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PlacesDetailsOptions {
|
|
14
|
+
language?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function fetchPlaceAutocomplete(
|
|
19
|
+
input: string,
|
|
20
|
+
{ components = 'country:tr', language = 'tr', signal }: PlacesAutocompleteOptions = {}
|
|
21
|
+
): Promise<GooglePlacePrediction[]> {
|
|
22
|
+
const apiKey = getGoogleMapsApiKey();
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error('Google Maps API key not configured');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json');
|
|
28
|
+
url.searchParams.set('input', input);
|
|
29
|
+
url.searchParams.set('components', components);
|
|
30
|
+
url.searchParams.set('language', language);
|
|
31
|
+
url.searchParams.set('key', apiKey);
|
|
32
|
+
|
|
33
|
+
const response = await fetch(url.toString(), { signal });
|
|
34
|
+
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Google Places API error: ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
|
|
41
|
+
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
|
42
|
+
throw new Error('Places API error');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return data.predictions || [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function fetchPlaceDetails(
|
|
49
|
+
placeId: string,
|
|
50
|
+
{ language = 'tr', signal }: PlacesDetailsOptions = {}
|
|
51
|
+
): Promise<GooglePlaceDetails> {
|
|
52
|
+
const apiKey = getGoogleMapsApiKey();
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
throw new Error('Google Maps API key not configured');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const url = new URL('https://maps.googleapis.com/maps/api/place/details/json');
|
|
58
|
+
url.searchParams.set('place_id', placeId);
|
|
59
|
+
url.searchParams.set('fields', 'geometry,address_components,formatted_address');
|
|
60
|
+
url.searchParams.set('language', language);
|
|
61
|
+
url.searchParams.set('key', apiKey);
|
|
62
|
+
|
|
63
|
+
const response = await fetch(url.toString(), { signal });
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`Google Places API error: ${response.status}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
|
|
71
|
+
if (data.status !== 'OK') {
|
|
72
|
+
throw new Error('Places API error');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return data.result;
|
|
76
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { Loader2, MapPin, Search } from 'lucide-react';
|
|
5
|
+
import { GooglePlacePrediction } from '@/core/feature/address/util/address.types';
|
|
6
|
+
|
|
7
|
+
interface AddressSearchInputProps {
|
|
8
|
+
query: string;
|
|
9
|
+
onQueryChange: (value: string) => void;
|
|
10
|
+
results: GooglePlacePrediction[];
|
|
11
|
+
isSearching: boolean;
|
|
12
|
+
showResults: boolean;
|
|
13
|
+
onShowResultsChange: (show: boolean) => void;
|
|
14
|
+
onSelect: (placeId: string) => void;
|
|
15
|
+
error?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
placeholder: string;
|
|
18
|
+
noResultsText: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AddressSearchInput({
|
|
22
|
+
query,
|
|
23
|
+
onQueryChange,
|
|
24
|
+
results,
|
|
25
|
+
isSearching,
|
|
26
|
+
showResults,
|
|
27
|
+
onShowResultsChange,
|
|
28
|
+
onSelect,
|
|
29
|
+
error,
|
|
30
|
+
label,
|
|
31
|
+
placeholder,
|
|
32
|
+
noResultsText,
|
|
33
|
+
}: AddressSearchInputProps) {
|
|
34
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
38
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
39
|
+
onShowResultsChange(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
43
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
44
|
+
}, [onShowResultsChange]);
|
|
45
|
+
|
|
46
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
47
|
+
if (e.key === 'Escape') {
|
|
48
|
+
onShowResultsChange(false);
|
|
49
|
+
}
|
|
50
|
+
if (e.key === 'Enter' && results.length > 0) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
onSelect(results[0].place_id);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const showDropdown = showResults && query.length >= 3;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div ref={containerRef} className="relative">
|
|
60
|
+
{label ? (
|
|
61
|
+
<label htmlFor="address-search" className="rizzui-input-label mb-1.5 block text-sm font-medium">
|
|
62
|
+
{label}
|
|
63
|
+
</label>
|
|
64
|
+
) : null}
|
|
65
|
+
<div className="relative">
|
|
66
|
+
<input
|
|
67
|
+
id="address-search"
|
|
68
|
+
type="text"
|
|
69
|
+
value={query}
|
|
70
|
+
onChange={e => onQueryChange(e.target.value)}
|
|
71
|
+
onFocus={() => {
|
|
72
|
+
if (query.length >= 3 && results.length > 0) onShowResultsChange(true);
|
|
73
|
+
}}
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
autoComplete="off"
|
|
77
|
+
className={`rizzui-input-container peer flex h-12 w-full items-center rounded-xl border bg-white py-2 pl-11 pr-11 text-sm shadow-sm transition-all placeholder:text-gray-400 focus:border-[#CF0A2C] focus:outline-none focus:ring-2 focus:ring-[#CF0A2C]/25 ${
|
|
78
|
+
error ? 'border-red-400 focus:border-red-400 focus:ring-red-500/20' : 'border-gray-200'
|
|
79
|
+
}`}
|
|
80
|
+
/>
|
|
81
|
+
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-400" />
|
|
82
|
+
{isSearching ? (
|
|
83
|
+
<Loader2 className="absolute right-3.5 top-1/2 h-[18px] w-[18px] -translate-y-1/2 animate-spin text-[#CF0A2C]" />
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{error ? <p className="mt-2 text-xs text-red-500">{error}</p> : null}
|
|
88
|
+
|
|
89
|
+
{showDropdown ? (
|
|
90
|
+
<div className="absolute z-50 mt-2 w-full overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl">
|
|
91
|
+
{results.length > 0 ? (
|
|
92
|
+
<ul className="max-h-64 overflow-y-auto py-1">
|
|
93
|
+
{results.map(result => (
|
|
94
|
+
<li key={result.place_id}>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onMouseDown={e => e.preventDefault()}
|
|
98
|
+
onClick={() => onSelect(result.place_id)}
|
|
99
|
+
className="w-full px-4 py-3 text-left transition-colors hover:bg-red-50/80 focus:bg-red-50 focus:outline-none"
|
|
100
|
+
>
|
|
101
|
+
<div className="flex items-start gap-3">
|
|
102
|
+
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-100">
|
|
103
|
+
<MapPin className="h-3.5 w-3.5 text-gray-500" />
|
|
104
|
+
</div>
|
|
105
|
+
<div className="min-w-0 flex-1">
|
|
106
|
+
<p className="truncate text-sm font-medium text-gray-900">
|
|
107
|
+
{result.structured_formatting.main_text}
|
|
108
|
+
</p>
|
|
109
|
+
<p className="mt-0.5 truncate text-xs text-gray-500">
|
|
110
|
+
{result.structured_formatting.secondary_text || result.description}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</button>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
) : (
|
|
119
|
+
!isSearching && <p className="px-4 py-6 text-center text-sm text-gray-500">{noResultsText}</p>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
) : null}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { nexineAxios } from "@/core/util/nexine.axios";
|
|
2
|
+
import { IData } from "@/core/interface/nexine.interface";
|
|
3
|
+
import { Address } from "@/core/interface/prisma.interface";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
|
|
6
|
+
export const useAddress = () => {
|
|
7
|
+
|
|
8
|
+
const queryClient = useQueryClient();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
fetch : () => {
|
|
12
|
+
return useQuery({
|
|
13
|
+
queryKey: ['web','address'],
|
|
14
|
+
queryFn: async () => {
|
|
15
|
+
var response = await nexineAxios.get<IData<Address>>('/customer/address')
|
|
16
|
+
return response.data;
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
create : () => {
|
|
21
|
+
return useMutation({
|
|
22
|
+
mutationFn: async (data: any) => {
|
|
23
|
+
var respose = await nexineAxios.post('/customer/address', data)
|
|
24
|
+
return respose.data;
|
|
25
|
+
},
|
|
26
|
+
onSuccess: () => {
|
|
27
|
+
queryClient.invalidateQueries({ queryKey: ['web','address'] })
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
detail : (id : string) => {
|
|
32
|
+
return useQuery({
|
|
33
|
+
queryKey: ['web','address','detail',id],
|
|
34
|
+
enabled : !!id && id !== "" && id != "create",
|
|
35
|
+
queryFn: async () => {
|
|
36
|
+
var response = await nexineAxios.get<Address>(`/customer/address/${id}`)
|
|
37
|
+
return response.data;
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
update : () => {
|
|
42
|
+
return useMutation({
|
|
43
|
+
mutationFn: (data: any) => {
|
|
44
|
+
return nexineAxios.put('/customer/address', data)
|
|
45
|
+
},
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
queryClient.invalidateQueries({ queryKey: ['web','address'] })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
delete : () => {
|
|
53
|
+
return useMutation({
|
|
54
|
+
mutationFn: (id: string) => {
|
|
55
|
+
return nexineAxios.delete(`/customer/address/${id}`)
|
|
56
|
+
},
|
|
57
|
+
onSuccess: () => {
|
|
58
|
+
queryClient.invalidateQueries({ queryKey: ['web','address'] })
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { fetchPlaceAutocomplete, fetchPlaceDetails } from '@/core/feature/address/api/places.api';
|
|
3
|
+
import { GooglePlacePrediction } from '@/core/feature/address/util/address.types';
|
|
4
|
+
import { parseGooglePlaceResult } from '@/core/feature/address/util/parse-google-place';
|
|
5
|
+
|
|
6
|
+
const DEBOUNCE_MS = 400;
|
|
7
|
+
const MIN_QUERY_LENGTH = 3;
|
|
8
|
+
|
|
9
|
+
export function useAddressAutocomplete() {
|
|
10
|
+
const [query, setQuery] = useState('');
|
|
11
|
+
const [results, setResults] = useState<GooglePlacePrediction[]>([]);
|
|
12
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
13
|
+
const [isSelecting, setIsSelecting] = useState(false);
|
|
14
|
+
const [showResults, setShowResults] = useState(false);
|
|
15
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
16
|
+
const searchOnChangeRef = useRef(false);
|
|
17
|
+
|
|
18
|
+
const searchAddresses = useCallback(async (input: string) => {
|
|
19
|
+
if (!input || input.length < MIN_QUERY_LENGTH) {
|
|
20
|
+
setResults([]);
|
|
21
|
+
setShowResults(false);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
abortRef.current?.abort();
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
abortRef.current = controller;
|
|
28
|
+
|
|
29
|
+
setIsSearching(true);
|
|
30
|
+
try {
|
|
31
|
+
const predictions = await fetchPlaceAutocomplete(input, {
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
});
|
|
34
|
+
setResults(predictions);
|
|
35
|
+
setShowResults(true);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as Error).name !== 'AbortError') {
|
|
38
|
+
setResults([]);
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
setIsSearching(false);
|
|
42
|
+
}
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const setQueryValue = useCallback((value: string, options?: { search?: boolean }) => {
|
|
46
|
+
const shouldSearch = options?.search ?? false;
|
|
47
|
+
searchOnChangeRef.current = shouldSearch;
|
|
48
|
+
|
|
49
|
+
setQuery(prev => {
|
|
50
|
+
if (prev === value) {
|
|
51
|
+
if (!shouldSearch) searchOnChangeRef.current = false;
|
|
52
|
+
return prev;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!shouldSearch) {
|
|
58
|
+
abortRef.current?.abort();
|
|
59
|
+
setResults([]);
|
|
60
|
+
setShowResults(false);
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!searchOnChangeRef.current) return;
|
|
66
|
+
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
searchAddresses(query);
|
|
69
|
+
searchOnChangeRef.current = false;
|
|
70
|
+
}, DEBOUNCE_MS);
|
|
71
|
+
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}, [query, searchAddresses]);
|
|
74
|
+
|
|
75
|
+
const selectPlace = useCallback(
|
|
76
|
+
async (prediction: GooglePlacePrediction) => {
|
|
77
|
+
setIsSelecting(true);
|
|
78
|
+
setShowResults(false);
|
|
79
|
+
try {
|
|
80
|
+
const result = await fetchPlaceDetails(prediction.place_id);
|
|
81
|
+
const parsed = parseGooglePlaceResult(result);
|
|
82
|
+
setQueryValue(parsed.formattedAddress, { search: false });
|
|
83
|
+
return parsed;
|
|
84
|
+
} finally {
|
|
85
|
+
setIsSelecting(false);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
[setQueryValue]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const resetSearch = useCallback(() => {
|
|
92
|
+
setQueryValue('', { search: false });
|
|
93
|
+
}, [setQueryValue]);
|
|
94
|
+
|
|
95
|
+
const setQueryProgrammatic = useCallback(
|
|
96
|
+
(value: string) => setQueryValue(value, { search: false }),
|
|
97
|
+
[setQueryValue]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const onUserQueryChange = useCallback(
|
|
101
|
+
(value: string) => setQueryValue(value, { search: true }),
|
|
102
|
+
[setQueryValue]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
query,
|
|
107
|
+
setQuery: setQueryProgrammatic,
|
|
108
|
+
onUserQueryChange,
|
|
109
|
+
results,
|
|
110
|
+
isSearching: isSearching || isSelecting,
|
|
111
|
+
showResults,
|
|
112
|
+
setShowResults,
|
|
113
|
+
selectPlace,
|
|
114
|
+
resetSearch,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface GooglePlacePrediction {
|
|
2
|
+
description: string;
|
|
3
|
+
place_id: string;
|
|
4
|
+
structured_formatting: {
|
|
5
|
+
main_text: string;
|
|
6
|
+
secondary_text: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GooglePlaceDetails {
|
|
11
|
+
geometry: {
|
|
12
|
+
location: {
|
|
13
|
+
lat: number;
|
|
14
|
+
lng: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
address_components: Array<{
|
|
18
|
+
long_name: string;
|
|
19
|
+
short_name: string;
|
|
20
|
+
types: string[];
|
|
21
|
+
}>;
|
|
22
|
+
formatted_address: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedAddressData {
|
|
26
|
+
streetNumber: string;
|
|
27
|
+
streetName: string;
|
|
28
|
+
neighborhood: string;
|
|
29
|
+
district: string;
|
|
30
|
+
city: string;
|
|
31
|
+
province: string;
|
|
32
|
+
country: string;
|
|
33
|
+
countryCode: string;
|
|
34
|
+
postalCode: string;
|
|
35
|
+
latitude: number;
|
|
36
|
+
longitude: number;
|
|
37
|
+
formattedAddress: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Address } from '@/core/interface/prisma.interface';
|
|
2
|
+
import { GooglePlaceDetails, ParsedAddressData } from '@/core/feature/address/util/address.types';
|
|
3
|
+
|
|
4
|
+
function getAddressComponent(
|
|
5
|
+
components: GooglePlaceDetails['address_components'],
|
|
6
|
+
types: string[],
|
|
7
|
+
useShort = false
|
|
8
|
+
) {
|
|
9
|
+
const component = components.find(comp => types.some(type => comp.types.includes(type)));
|
|
10
|
+
return useShort ? component?.short_name || '' : component?.long_name || '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseGooglePlaceResult(placeDetails: GooglePlaceDetails): ParsedAddressData {
|
|
14
|
+
const components = placeDetails.address_components;
|
|
15
|
+
|
|
16
|
+
const district =
|
|
17
|
+
getAddressComponent(components, ['administrative_area_level_2']) ||
|
|
18
|
+
getAddressComponent(components, ['sublocality_level_1', 'sublocality']);
|
|
19
|
+
|
|
20
|
+
const city =
|
|
21
|
+
getAddressComponent(components, ['administrative_area_level_1']) ||
|
|
22
|
+
getAddressComponent(components, ['locality', 'postal_town']);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
streetNumber: getAddressComponent(components, ['street_number']),
|
|
26
|
+
streetName: getAddressComponent(components, ['route']),
|
|
27
|
+
neighborhood: getAddressComponent(components, ['neighborhood', 'sublocality_level_1', 'sublocality']),
|
|
28
|
+
district,
|
|
29
|
+
city,
|
|
30
|
+
province: getAddressComponent(components, ['administrative_area_level_1']),
|
|
31
|
+
country: getAddressComponent(components, ['country']),
|
|
32
|
+
countryCode: getAddressComponent(components, ['country'], true),
|
|
33
|
+
postalCode: getAddressComponent(components, ['postal_code']),
|
|
34
|
+
latitude: placeDetails.geometry.location.lat,
|
|
35
|
+
longitude: placeDetails.geometry.location.lng,
|
|
36
|
+
formattedAddress: placeDetails.formatted_address,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatAddressSummary(data: Partial<Address> | ParsedAddressData): string {
|
|
41
|
+
const street = [data.streetName, 'streetNumber' in data ? data.streetNumber : undefined].filter(Boolean).join(' ');
|
|
42
|
+
const cityLine = [
|
|
43
|
+
data.postalCode,
|
|
44
|
+
'city' in data ? data.city || data.province : data.administrativeAreaLevel1,
|
|
45
|
+
'district' in data ? data.district : data.administrativeAreaLevel2,
|
|
46
|
+
]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(' ');
|
|
49
|
+
const country = data.country;
|
|
50
|
+
|
|
51
|
+
return [street, cityLine, country].filter(Boolean).join(', ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parsedAddressToFormFields(parsed: ParsedAddressData) {
|
|
55
|
+
const streetParts = [parsed.neighborhood, parsed.streetName].filter(Boolean);
|
|
56
|
+
const street = streetParts.join(', ');
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
street: street || parsed.formattedAddress,
|
|
60
|
+
buildingNumber: parsed.streetNumber,
|
|
61
|
+
apartmentNumber: '',
|
|
62
|
+
postalCode: parsed.postalCode,
|
|
63
|
+
provinceName: parsed.province || parsed.city,
|
|
64
|
+
districtName: parsed.district,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AnalyticEventType } from '@/core/interface/prisma.interface';
|
|
2
|
+
import { nexineAxios } from '@/core/util/nexine.axios';
|
|
3
|
+
|
|
4
|
+
export interface ProductAnalyticPayload {
|
|
5
|
+
productId: string;
|
|
6
|
+
variantId?: string | null;
|
|
7
|
+
eventType?: AnalyticEventType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EmailClickedAnalyticPayload {
|
|
11
|
+
trackingCode: string;
|
|
12
|
+
eventType: AnalyticEventType.EMAIL_CLICKED;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AnalyticEventPayload = ProductAnalyticPayload | EmailClickedAnalyticPayload;
|
|
16
|
+
|
|
17
|
+
export async function sendAnalyticEvents(events: AnalyticEventPayload[]) {
|
|
18
|
+
if (events.length === 0) return;
|
|
19
|
+
|
|
20
|
+
await nexineAxios.patch(
|
|
21
|
+
'/customer/analytic-event',
|
|
22
|
+
events.map((event) => ({
|
|
23
|
+
...event,
|
|
24
|
+
eventType: 'eventType' in event && event.eventType ? event.eventType : AnalyticEventType.VIEW,
|
|
25
|
+
}))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
|
4
|
+
import { useSearchParams, useRouter } from 'next/navigation';
|
|
5
|
+
import { AnalyticEventType } from '@/core/interface/prisma.interface';
|
|
6
|
+
import { sendAnalyticEvents } from './analytic.event.api';
|
|
7
|
+
import { useAnalyticAutoTrack } from './use.analytic.auto';
|
|
8
|
+
|
|
9
|
+
interface ProductAnalyticEvent {
|
|
10
|
+
productId: string;
|
|
11
|
+
variantId?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface EmailClickedEvent {
|
|
15
|
+
tcx: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface AnalyticEventContextType {
|
|
19
|
+
trackProductView: (event: ProductAnalyticEvent) => void;
|
|
20
|
+
trackProductClick: (event: ProductAnalyticEvent) => void;
|
|
21
|
+
trackEmailClicked: (event: EmailClickedEvent) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AnalyticEventContext = createContext<AnalyticEventContextType | undefined>(undefined);
|
|
25
|
+
|
|
26
|
+
export interface AnalyticEventProviderProps {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
productPathPrefix?: string;
|
|
29
|
+
autoTrack?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const AnalyticEventProvider = ({
|
|
33
|
+
children,
|
|
34
|
+
productPathPrefix = '/urunler',
|
|
35
|
+
autoTrack = true,
|
|
36
|
+
}: AnalyticEventProviderProps) => {
|
|
37
|
+
const bufferRef = useRef<ProductAnalyticEvent[]>([]);
|
|
38
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
const searchParams = useSearchParams();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const tcxProcessedRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
const flush = useCallback(async () => {
|
|
44
|
+
const events = [...bufferRef.current];
|
|
45
|
+
if (events.length === 0) return;
|
|
46
|
+
|
|
47
|
+
bufferRef.current = [];
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await sendAnalyticEvents(
|
|
51
|
+
events.map((event) => ({
|
|
52
|
+
productId: event.productId,
|
|
53
|
+
variantId: event.variantId,
|
|
54
|
+
eventType: AnalyticEventType.VIEW,
|
|
55
|
+
}))
|
|
56
|
+
);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Failed to send product view events', error);
|
|
59
|
+
}
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const trackProductView = useCallback(
|
|
63
|
+
(event: ProductAnalyticEvent) => {
|
|
64
|
+
if (!event.productId) return;
|
|
65
|
+
|
|
66
|
+
const exists = bufferRef.current.some(
|
|
67
|
+
(item) => item.productId === event.productId && item.variantId === event.variantId
|
|
68
|
+
);
|
|
69
|
+
if (!exists) {
|
|
70
|
+
bufferRef.current.push(event);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!timeoutRef.current) {
|
|
74
|
+
timeoutRef.current = setTimeout(() => {
|
|
75
|
+
flush();
|
|
76
|
+
timeoutRef.current = null;
|
|
77
|
+
}, 2000);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[flush]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const trackProductClick = useCallback((event: ProductAnalyticEvent) => {
|
|
84
|
+
if (!event.productId) return;
|
|
85
|
+
|
|
86
|
+
sendAnalyticEvents([
|
|
87
|
+
{
|
|
88
|
+
productId: event.productId,
|
|
89
|
+
variantId: event.variantId,
|
|
90
|
+
eventType: AnalyticEventType.CLICKED,
|
|
91
|
+
},
|
|
92
|
+
]).catch((error) => {
|
|
93
|
+
console.error('Failed to send product click event', error);
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const removeTcxFromUrl = useCallback(() => {
|
|
98
|
+
const currentUrl = new URL(window.location.href);
|
|
99
|
+
if (!currentUrl.searchParams.has('tcx')) return;
|
|
100
|
+
|
|
101
|
+
currentUrl.searchParams.delete('tcx');
|
|
102
|
+
router.replace(currentUrl.pathname + currentUrl.search, { scroll: false });
|
|
103
|
+
}, [router]);
|
|
104
|
+
|
|
105
|
+
const trackEmailClicked = useCallback(
|
|
106
|
+
(event: EmailClickedEvent) => {
|
|
107
|
+
if (!event.tcx) return;
|
|
108
|
+
|
|
109
|
+
sendAnalyticEvents([
|
|
110
|
+
{
|
|
111
|
+
trackingCode: event.tcx,
|
|
112
|
+
eventType: AnalyticEventType.EMAIL_CLICKED,
|
|
113
|
+
},
|
|
114
|
+
])
|
|
115
|
+
.then(() => removeTcxFromUrl())
|
|
116
|
+
.catch((error) => {
|
|
117
|
+
console.error('Failed to send email clicked event', error);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
[removeTcxFromUrl]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
useAnalyticAutoTrack({
|
|
124
|
+
enabled: autoTrack,
|
|
125
|
+
productPathPrefix,
|
|
126
|
+
trackProductView,
|
|
127
|
+
trackProductClick,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (tcxProcessedRef.current) return;
|
|
132
|
+
|
|
133
|
+
const tcx = searchParams.get('tcx');
|
|
134
|
+
if (!tcx) return;
|
|
135
|
+
|
|
136
|
+
tcxProcessedRef.current = true;
|
|
137
|
+
trackEmailClicked({ tcx });
|
|
138
|
+
}, [searchParams, trackEmailClicked]);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const handleUnload = () => {
|
|
142
|
+
if (timeoutRef.current) {
|
|
143
|
+
clearTimeout(timeoutRef.current);
|
|
144
|
+
timeoutRef.current = null;
|
|
145
|
+
}
|
|
146
|
+
flush();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleVisibilityChange = () => {
|
|
150
|
+
if (document.visibilityState === 'hidden') {
|
|
151
|
+
handleUnload();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
window.addEventListener('beforeunload', handleUnload);
|
|
156
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
window.removeEventListener('beforeunload', handleUnload);
|
|
160
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
161
|
+
if (timeoutRef.current) {
|
|
162
|
+
clearTimeout(timeoutRef.current);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}, [flush]);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<AnalyticEventContext.Provider value={{ trackProductView, trackProductClick, trackEmailClicked }}>
|
|
169
|
+
{children}
|
|
170
|
+
</AnalyticEventContext.Provider>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const useAnalyticEvent = () => {
|
|
175
|
+
const context = useContext(AnalyticEventContext);
|
|
176
|
+
if (!context) {
|
|
177
|
+
throw new Error('useAnalyticEvent must be used within an AnalyticEventProvider');
|
|
178
|
+
}
|
|
179
|
+
return context;
|
|
180
|
+
};
|