mirror of
https://github.com/catdevnull/preciazo.git
synced 2024-11-25 19:16:19 +00:00
Compare commits
6 commits
b05776a736
...
4db1f0b09e
Author | SHA1 | Date | |
---|---|---|---|
4db1f0b09e | |||
ddb202dc93 | |||
1f9daaa016 | |||
4b2c360e17 | |||
9aff44b3d9 | |||
245dc2eb02 |
12 changed files with 1683 additions and 70 deletions
BIN
sepa/bun.lockb
BIN
sepa/bun.lockb
Binary file not shown.
|
@ -61,6 +61,7 @@ async function importSucursales(
|
|||
async function importDataset(dir: string) {
|
||||
console.log(dir);
|
||||
const date = basename(dir).match(/(\d{4}-\d{2}-\d{2})/)![1];
|
||||
const id_comercio = basename(dir).match(/comercio-sepa-(\d+)/)![1];
|
||||
// TODO: parsear "Ultima actualizacion" al final del CSV y insertarlo en la tabla datasets
|
||||
|
||||
// {
|
||||
|
@ -73,7 +74,7 @@ async function importDataset(dir: string) {
|
|||
await sql.begin(async (sql) => {
|
||||
let datasetId: number;
|
||||
const res =
|
||||
await sql`insert into datasets (name, date) values (${basename(dir)}, ${date}) returning id`;
|
||||
await sql`insert into datasets (name, date, id_comercio) values (${basename(dir)}, ${date}, ${id_comercio}) returning id`;
|
||||
datasetId = res[0].id;
|
||||
|
||||
const comercios: Papa.ParseResult<{ comercio_cuit: string }> = Papa.parse(
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-leaflet": "^0.0.22",
|
||||
"@sentry/sveltekit": "^8.30.0",
|
||||
"@types/node": "^22.5.0",
|
||||
"bits-ui": "^0.21.13",
|
||||
|
@ -49,6 +50,7 @@
|
|||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lucide-svelte": "^0.441.0",
|
||||
"maplibre-gl": "^4.7.0",
|
||||
"postgres": "^3.4.4",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwind-variants": "^0.2.1"
|
||||
|
|
|
@ -1,33 +1,41 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Preciazo</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
|
||||
/>
|
||||
<meta content="index, follow" name="robots" />
|
||||
<meta property="og:title" content="Preciazo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
|
||||
/>
|
||||
<meta property="og:image" content="https://preciazo.nulo.in/favicon.png" />
|
||||
<meta property="og:url" content="https://preciazo.nulo.in" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<title>Preciazo</title>
|
||||
<meta name="description" content="Busca precios de productos en distintas cadenas de supermercados en Argentina." />
|
||||
<meta content="index, follow" name="robots" />
|
||||
<meta property="og:title" content="Preciazo" />
|
||||
<meta property="og:description"
|
||||
content="Busca precios de productos en distintas cadenas de supermercados en Argentina." />
|
||||
<meta property="og:image" content="https://preciazo.nulo.in/favicon.png" />
|
||||
<meta property="og:url" content={`https://preciazo.nulo.in${data.pathname}`} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:site" content="@esoesnulo" />
|
||||
<meta name="twitter:title" content="Preciazo" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://preciazo.nulo.in/favicon.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:site" content="@esoesnulo" />
|
||||
<meta name="twitter:title" content="Preciazo" />
|
||||
<meta name="twitter:description"
|
||||
content="Busca precios de productos en distintas cadenas de supermercados en Argentina." />
|
||||
<meta name="twitter:image" content="https://preciazo.nulo.in/favicon.png" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,14 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type L from 'leaflet';
|
||||
import style from './map_style.json';
|
||||
import L from 'leaflet';
|
||||
import 'maplibre-gl';
|
||||
import '@maplibre/maplibre-gl-leaflet';
|
||||
|
||||
let mapEl: HTMLDivElement;
|
||||
export let mapMap: (map: L.Map, l: typeof L) => void;
|
||||
let map: L.Map | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
const L = await import('leaflet');
|
||||
// Set up initial map center and zoom level
|
||||
map = L.map(mapEl, {
|
||||
center: [-34.599722222222, -58.381944444444], // EDIT latitude, longitude to re-center map
|
||||
|
@ -17,17 +19,14 @@
|
|||
// tap: false
|
||||
});
|
||||
|
||||
/* Control panel to display map layers */
|
||||
// var controlLayers = L.control.layers( null, null, {
|
||||
// position: "topright",
|
||||
// collapsed: false
|
||||
// }).addTo(map);
|
||||
L.maplibreGL({
|
||||
style: style as any,
|
||||
attribution:
|
||||
'© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>, © <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||
} as any).addTo(map);
|
||||
|
||||
// display Carto basemap tiles with light features and labels
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution:
|
||||
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attribution">CARTO</a>'
|
||||
}).addTo(map);
|
||||
// L.tileLayer.provider('Stadia.AlidadeSmoothBackground').addTo(map);
|
||||
mapMap(map, L);
|
||||
});
|
||||
|
||||
|
|
1539
sepa/sitio2/src/lib/components/map_style.json
Normal file
1539
sepa/sitio2/src/lib/components/map_style.json
Normal file
File diff suppressed because it is too large
Load diff
26
sepa/sitio2/src/routes/+error.svelte
Normal file
26
sepa/sitio2/src/routes/+error.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { ArrowLeftIcon, HomeIcon } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-screen-sm flex-col items-center justify-center gap-4 p-4">
|
||||
<h1 class="flex text-5xl font-bold">Error {$page.status} :(</h1>
|
||||
<p>{$page.error?.message}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
on:click={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeftIcon class="h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<Button variant="outline" href="/" class="flex items-center gap-2">
|
||||
<HomeIcon class="h-4 w-4" />
|
||||
Ir al inicio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
|
@ -1,7 +1,7 @@
|
|||
import { sql } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
export const load: PageServerLoad = async ({ setHeaders }) => {
|
||||
// https://www.cybertec-postgresql.com/en/postgresql-count-made-fast/
|
||||
const count = await sql`
|
||||
SELECT reltuples::bigint
|
||||
|
@ -9,6 +9,10 @@ export const load: PageServerLoad = async () => {
|
|||
WHERE relname = 'precios';
|
||||
`;
|
||||
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=600'
|
||||
});
|
||||
|
||||
return {
|
||||
count: count[0].reltuples
|
||||
};
|
||||
|
|
|
@ -2,7 +2,8 @@ import { db } from '$lib/server/db';
|
|||
import type { PageServerLoad } from './$types';
|
||||
import { datasets, precios, sucursales } from '$lib/server/db/schema';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
import { error } from '@sveltejs/kit';
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
const id = BigInt(params.id);
|
||||
const preciosRes = await db
|
||||
.select({
|
||||
|
@ -47,6 +48,14 @@ ORDER BY d1.id_comercio)
|
|||
)
|
||||
.leftJoin(datasets, eq(datasets.id, precios.id_dataset));
|
||||
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=600'
|
||||
});
|
||||
|
||||
if (preciosRes.length == 0) {
|
||||
return error(404, `Producto ${params.id} no encontrado`);
|
||||
}
|
||||
|
||||
// const precios = await sql<
|
||||
// {
|
||||
// productos_precio_lista: number;
|
||||
|
|
1
sepa/sitio2/src/routes/id_producto/[id]/+page.ts
Normal file
1
sepa/sitio2/src/routes/id_producto/[id]/+page.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
|
@ -2,7 +2,7 @@ import { db } from '$lib/server/db';
|
|||
import { sql } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
export const load: PageServerLoad = async ({ params, setHeaders }) => {
|
||||
// const latestDatasetsSq = db.$with('latest_datasets').as(
|
||||
// db.select({
|
||||
// id: datasets.id,
|
||||
|
@ -16,11 +16,17 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||
// // 'datasets.id_comercio = latest_datasets.id_comercio AND datasets.date = latest_datasets.max_date'
|
||||
// ))
|
||||
|
||||
const query = params.query;
|
||||
const query = params.query
|
||||
.replaceAll(/á/giu, 'a')
|
||||
.replaceAll(/é/giu, 'e')
|
||||
.replaceAll(/í/giu, 'i')
|
||||
.replaceAll(/ó/giu, 'o')
|
||||
.replaceAll(/ú/giu, 'u')
|
||||
.replaceAll(/ñ/giu, 'n');
|
||||
const productos = await db.execute<{
|
||||
id_producto: string;
|
||||
productos_descripcion: string;
|
||||
productos_marca: string;
|
||||
productos_marca: string | null;
|
||||
in_datasets_count: number;
|
||||
}>(sql`
|
||||
SELECT id_producto, productos_descripcion, productos_marca,
|
||||
|
@ -46,7 +52,7 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
|
|||
const existingProduct = acc.find((p) => p.id_producto === producto.id_producto);
|
||||
if (existingProduct) {
|
||||
existingProduct.descriptions.push(producto.productos_descripcion);
|
||||
existingProduct.marcas.add(producto.productos_marca);
|
||||
if (producto.productos_marca) existingProduct.marcas.add(producto.productos_marca);
|
||||
existingProduct.in_datasets_count = Math.max(
|
||||
existingProduct.in_datasets_count,
|
||||
producto.in_datasets_count
|
||||
|
@ -55,7 +61,7 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
|
|||
acc.push({
|
||||
id_producto: producto.id_producto,
|
||||
descriptions: [producto.productos_descripcion],
|
||||
marcas: new Set([producto.productos_marca]),
|
||||
marcas: new Set(producto.productos_marca ? [producto.productos_marca] : []),
|
||||
in_datasets_count: producto.in_datasets_count
|
||||
});
|
||||
}
|
||||
|
@ -69,6 +75,10 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
|
|||
}>
|
||||
);
|
||||
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, max-age=600'
|
||||
});
|
||||
|
||||
// 'latest_datasets',
|
||||
// sql`
|
||||
// WITH latest_datasets AS (
|
||||
|
|
|
@ -8,6 +8,17 @@
|
|||
import { goto } from '$app/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
function parseMarcas(marcas: readonly string[]) {
|
||||
const x = marcas
|
||||
.map((m) => m.trim().replaceAll(/['`´]/g, ''))
|
||||
.filter((m) => !['sin marca', 'VARIOS'].includes(m))
|
||||
.filter((m) => m.length > 0);
|
||||
if (x.length === 0) {
|
||||
return ['n/a'];
|
||||
}
|
||||
return Array.from(new Set(x));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
@ -21,28 +32,31 @@
|
|||
</Button>
|
||||
<SearchBar />
|
||||
<h1 class="my-2 text-2xl font-bold">Resultados para "{data.query}"</h1>
|
||||
{#each data.collapsedProductos as producto}
|
||||
<a href={`/id_producto/${producto.id_producto}`} class="my-2 block">
|
||||
<Card.Root class="transition-colors duration-200 hover:bg-gray-100">
|
||||
<Card.Header class="block px-3 py-2 pb-0">
|
||||
<Badge
|
||||
>{Array.from(producto.marcas)
|
||||
.filter((m) => !['sin marca', 'VARIOS'].includes(m))
|
||||
.filter((m) => m?.trim().length > 0)
|
||||
.join('/')}</Badge
|
||||
>
|
||||
<Badge variant="outline">en {producto.in_datasets_count} cadenas</Badge>
|
||||
<Badge variant="outline">EAN {producto.id_producto}</Badge>
|
||||
</Card.Header>
|
||||
<Card.Content class="px-3 py-2">
|
||||
{#each producto.descriptions as description}
|
||||
<span>{description}</span>
|
||||
{#if description !== producto.descriptions[producto.descriptions.length - 1]}
|
||||
<span class="text-gray-500">⋅</span>{' '}
|
||||
{/if}
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
{#if data.collapsedProductos.length === 0}
|
||||
<p class="my-2 text-gray-600">
|
||||
No se encontraron resultados para "{data.query}". Tené en cuenta que actualmente, el algoritmo
|
||||
de búsqueda es muy básico. Probá buscando palabras clave como "alfajor", "ketchup" o
|
||||
"lenteja".
|
||||
</p>
|
||||
{:else}
|
||||
{#each data.collapsedProductos as producto}
|
||||
<a href={`/id_producto/${producto.id_producto}`} class="my-2 block">
|
||||
<Card.Root class="transition-colors duration-200 hover:bg-gray-100">
|
||||
<Card.Header class="block px-3 py-2 pb-0">
|
||||
<Badge>{parseMarcas(Array.from(producto.marcas)).join('/')}</Badge>
|
||||
<Badge variant="outline">en {producto.in_datasets_count} cadenas</Badge>
|
||||
<Badge variant="outline">EAN {producto.id_producto}</Badge>
|
||||
</Card.Header>
|
||||
<Card.Content class="px-3 py-2">
|
||||
{#each producto.descriptions as description}
|
||||
<span>{description}</span>
|
||||
{#if description !== producto.descriptions[producto.descriptions.length - 1]}
|
||||
<span class="text-gray-500">⋅</span>{' '}
|
||||
{/if}
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue