Modulo makers3d desarrollado con codex V 0.0.1
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -0,0 +1,21 @@
|
||||
POSTGRES_DB=makers3d
|
||||
POSTGRES_USER=makers3d
|
||||
POSTGRES_PASSWORD=makers3d
|
||||
DATABASE_URL=postgres://makers3d:makers3d@postgres:5432/makers3d
|
||||
SESSION_SECRET=change-me
|
||||
API_PORT=4000
|
||||
WEB_PORT=3000
|
||||
APP_URL=https://dev.jazari.com.ar
|
||||
NEXT_PUBLIC_API_BASE_URL=/api/v1
|
||||
INTERNAL_API_URL=http://api:4000/api/v1
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_ENDPOINT=minio
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_PUBLIC_BUCKET=makers3d-public
|
||||
MINIO_PRIVATE_BUCKET=makers3d-private
|
||||
MERCADOPAGO_ACCESS_TOKEN=
|
||||
PAYMENT_PROVIDER=demo
|
||||
ADMIN_EMAIL=admin@makers3d.local
|
||||
ADMIN_PASSWORD=Admin123!
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
tmp/
|
||||
data/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Makers3D MVP
|
||||
|
||||
Implementacion funcional del MVP documentado en `docs/`.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## Arranque rapido
|
||||
|
||||
1. Copiar `.env.example` a `.env`.
|
||||
2. Ejecutar `docker compose up --build -d`.
|
||||
3. Ejecutar migraciones y seed:
|
||||
- `docker compose exec api npm run db:migrate`
|
||||
- `docker compose exec api npm run db:seed`
|
||||
4. Abrir `http://localhost`.
|
||||
|
||||
## Credenciales demo
|
||||
|
||||
- Admin: `admin@makers3d.local` / `Admin123!`
|
||||
- Maker demo: `maker1@makers3d.local` / `Maker123!`
|
||||
- Cliente demo: `cliente1@makers3d.local` / `Cliente123!`
|
||||
|
||||
## Servicios
|
||||
|
||||
- `web`: Next.js
|
||||
- `api`: Fastify + PostgreSQL
|
||||
- `worker`: tareas asincronas simples
|
||||
- `postgres`: base de datos
|
||||
- `minio`: almacenamiento S3-compatible
|
||||
- `nginx`: proxy de entrada
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build -w @makers3d/shared && npm run build -w @makers3d/api
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/src/db/schema.sql ./apps/api/src/db/schema.sql
|
||||
COPY --from=build /app/packages/shared/package.json ./packages/shared/package.json
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "apps/api/dist/apps/api/src/index.js"]
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@makers3d/api",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/apps/api/src/index.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"db:migrate": "node dist/apps/api/src/scripts/migrate.js",
|
||||
"db:seed": "node dist/apps/api/src/scripts/seed.js",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/multipart": "^9.2.1",
|
||||
"@makers3d/shared": "0.1.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.6.1",
|
||||
"minio": "^8.0.6",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { query, one } from "./db.js";
|
||||
|
||||
export const sessionMaxAgeSeconds = 60 * 60 * 24 * 400;
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
currentUser: CurrentUser | null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(token);
|
||||
await query(
|
||||
`
|
||||
insert into sessions (user_id, token_hash, expires_at)
|
||||
values ($1, $2, now() + ($3::int * interval '1 second'))
|
||||
`,
|
||||
[userId, tokenHash, sessionMaxAgeSeconds]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession(token: string): Promise<void> {
|
||||
await query(`delete from sessions where token_hash = $1`, [hashToken(token)]);
|
||||
}
|
||||
|
||||
export async function refreshSession(token: string): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
update sessions
|
||||
set expires_at = now() + ($2::int * interval '1 second')
|
||||
where token_hash = $1
|
||||
and expires_at > now()
|
||||
`,
|
||||
[hashToken(token), sessionMaxAgeSeconds]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserFromSessionToken(token: string): Promise<CurrentUser | null> {
|
||||
return one<CurrentUser>(
|
||||
`
|
||||
select u.id, u.email, u.role
|
||||
from sessions s
|
||||
join users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.expires_at > now()
|
||||
`,
|
||||
[hashToken(token)]
|
||||
);
|
||||
}
|
||||
|
||||
export async function attachCurrentUser(request: FastifyRequest): Promise<void> {
|
||||
const token = request.cookies.makers3d_session;
|
||||
if (!token) {
|
||||
request.currentUser = null;
|
||||
return;
|
||||
}
|
||||
|
||||
request.currentUser = await getUserFromSessionToken(token);
|
||||
}
|
||||
|
||||
export function requireAuth(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
|
||||
if (!request.currentUser) {
|
||||
reply.code(401).send({ error: "Authentication required" });
|
||||
return null;
|
||||
}
|
||||
return request.currentUser;
|
||||
}
|
||||
|
||||
export function requireAdmin(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
|
||||
if (!request.currentUser || request.currentUser.role !== "admin") {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
return request.currentUser;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const booleanFromEnv = z.preprocess((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
return false;
|
||||
}, z.boolean());
|
||||
|
||||
const configSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SESSION_SECRET: z.string().min(8).default("change-me"),
|
||||
API_PORT: z.coerce.number().default(4000),
|
||||
APP_URL: z.string().default("http://localhost"),
|
||||
MINIO_ROOT_USER: z.string().default("minioadmin"),
|
||||
MINIO_ROOT_PASSWORD: z.string().default("minioadmin"),
|
||||
MINIO_ENDPOINT: z.string().default("minio"),
|
||||
MINIO_PORT: z.coerce.number().default(9000),
|
||||
MINIO_USE_SSL: booleanFromEnv.default(false),
|
||||
MINIO_PUBLIC_BUCKET: z.string().default("makers3d-public"),
|
||||
MINIO_PRIVATE_BUCKET: z.string().default("makers3d-private"),
|
||||
PAYMENT_PROVIDER: z.enum(["demo", "mercadopago"]).default("demo"),
|
||||
MERCADOPAGO_ACCESS_TOKEN: z.string().optional(),
|
||||
ADMIN_EMAIL: z.string().email().default("admin@makers3d.local"),
|
||||
ADMIN_PASSWORD: z.string().min(8).default("Admin123!")
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: config.DATABASE_URL
|
||||
});
|
||||
|
||||
export async function query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const result = await pool.query<T>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function one<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
full_name text not null,
|
||||
role text not null default 'user' check (role in ('user', 'admin')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists maker_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null unique references users(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
business_name text,
|
||||
description text,
|
||||
province text,
|
||||
city text,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
public_latitude double precision,
|
||||
public_longitude double precision,
|
||||
delivery_scope text,
|
||||
availability text not null default 'available' check (availability in ('available', 'limited', 'unavailable')),
|
||||
public_contact_email text,
|
||||
public_whatsapp text,
|
||||
website_url text,
|
||||
instagram_url text,
|
||||
tiktok_url text,
|
||||
twitter_url text,
|
||||
facebook_url text,
|
||||
youtube_url text,
|
||||
linkedin_url text,
|
||||
allow_verified_reviews boolean not null default true,
|
||||
show_satisfaction_score boolean not null default true,
|
||||
show_review_tags boolean not null default true,
|
||||
allow_maker_review_reply boolean not null default true,
|
||||
show_past_reviews boolean not null default true,
|
||||
status text not null default 'draft' check (status in ('draft', 'published', 'suspended')),
|
||||
main_image_url text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table maker_profiles add column if not exists website_url text;
|
||||
alter table maker_profiles add column if not exists instagram_url text;
|
||||
alter table maker_profiles add column if not exists tiktok_url text;
|
||||
alter table maker_profiles add column if not exists twitter_url text;
|
||||
alter table maker_profiles add column if not exists facebook_url text;
|
||||
alter table maker_profiles add column if not exists youtube_url text;
|
||||
alter table maker_profiles add column if not exists linkedin_url text;
|
||||
alter table maker_profiles add column if not exists allow_verified_reviews boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_satisfaction_score boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_review_tags boolean not null default true;
|
||||
alter table maker_profiles add column if not exists allow_maker_review_reply boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_past_reviews boolean not null default true;
|
||||
|
||||
create table if not exists services (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
category text not null,
|
||||
description text not null,
|
||||
technologies text[] not null default '{}',
|
||||
materials text[] not null default '{}',
|
||||
local_pickup boolean not null default true,
|
||||
nationwide_shipping boolean not null default false,
|
||||
lead_time_days integer not null default 3,
|
||||
price_from_cents integer,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table services add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists works (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
service_id uuid references services(id) on delete set null,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
summary text not null,
|
||||
story text not null,
|
||||
technology text not null,
|
||||
material text not null,
|
||||
image_url text not null,
|
||||
gallery_urls text[] not null default '{}',
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table works add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists showcases (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
description text not null,
|
||||
cover_image_url text,
|
||||
selected_work_ids uuid[] not null default '{}',
|
||||
featured_work_id uuid references works(id) on delete set null,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table showcases add column if not exists cover_image_url text;
|
||||
alter table showcases add column if not exists selected_work_ids uuid[] not null default '{}';
|
||||
alter table showcases add column if not exists featured_work_id uuid references works(id) on delete set null;
|
||||
alter table showcases add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists maker_locations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
label text not null,
|
||||
location_type text not null,
|
||||
address text,
|
||||
postal_code text,
|
||||
province text not null,
|
||||
city text not null,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
privacy text not null default 'approximate',
|
||||
local_pickup boolean not null default true,
|
||||
personal_delivery boolean not null default true,
|
||||
postal_shipping boolean not null default true,
|
||||
nationwide_shipping boolean not null default false,
|
||||
mobile_service boolean not null default false,
|
||||
onsite_service boolean not null default false,
|
||||
notes text,
|
||||
travel_radius text,
|
||||
shipping_area text,
|
||||
hours_mode text not null default 'appointment',
|
||||
alert_service text,
|
||||
alert_radius text,
|
||||
minimum_rating text,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table maker_locations add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
alter table maker_locations add column if not exists is_published boolean not null default false;
|
||||
|
||||
create table if not exists subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
provider text not null,
|
||||
plan_code text not null,
|
||||
status text not null check (status in ('inactive', 'pending', 'active', 'grace', 'cancelled')),
|
||||
external_reference text,
|
||||
activated_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists inquiries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
customer_id uuid not null references users(id) on delete cascade,
|
||||
source_type text not null,
|
||||
source_id uuid,
|
||||
need_text text not null,
|
||||
size_text text,
|
||||
has_model boolean not null default false,
|
||||
material_preference text,
|
||||
quantity integer not null default 1,
|
||||
urgency text not null,
|
||||
delivery_type text not null,
|
||||
status text not null default 'open' check (status in ('open', 'rejected', 'completed')),
|
||||
rejection_reason text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table inquiries drop constraint if exists inquiries_status_check;
|
||||
alter table inquiries add constraint inquiries_status_check check (status in ('open', 'agreed', 'completed', 'rejected', 'incident'));
|
||||
alter table inquiries add column if not exists proposal_price_cents integer;
|
||||
alter table inquiries add column if not exists proposal_quantity integer;
|
||||
alter table inquiries add column if not exists proposal_lead_time_days integer;
|
||||
alter table inquiries add column if not exists proposal_note text;
|
||||
alter table inquiries add column if not exists proposal_created_at timestamptz;
|
||||
alter table inquiries add column if not exists agreed_at timestamptz;
|
||||
alter table inquiries add column if not exists maker_completed_at timestamptz;
|
||||
alter table inquiries add column if not exists customer_completed_at timestamptz;
|
||||
alter table inquiries add column if not exists completed_at timestamptz;
|
||||
alter table inquiries add column if not exists incident_reason text;
|
||||
alter table inquiries add column if not exists incident_detail text;
|
||||
alter table inquiries add column if not exists incident_created_at timestamptz;
|
||||
update inquiries set completed_at = updated_at where status = 'completed' and completed_at is null;
|
||||
|
||||
create table if not exists messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
inquiry_id uuid not null references inquiries(id) on delete cascade,
|
||||
sender_user_id uuid not null references users(id) on delete cascade,
|
||||
body text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists reviews (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
inquiry_id uuid not null unique references inquiries(id) on delete cascade,
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
customer_id uuid not null references users(id) on delete cascade,
|
||||
rating_overall integer not null check (rating_overall between 1 and 5),
|
||||
rating_quality integer not null check (rating_quality between 1 and 5),
|
||||
rating_communication integer not null check (rating_communication between 1 and 5),
|
||||
rating_value integer not null check (rating_value between 1 and 5),
|
||||
comment text not null,
|
||||
status text not null default 'published' check (status in ('published', 'reported', 'hidden')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists favorite_makers (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (user_id, maker_id)
|
||||
);
|
||||
|
||||
create table if not exists favorite_works (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
work_id uuid not null references works(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (user_id, work_id)
|
||||
);
|
||||
|
||||
create table if not exists moderation_cases (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
target_type text not null,
|
||||
target_id uuid not null,
|
||||
reporter_user_id uuid references users(id) on delete set null,
|
||||
reason text not null,
|
||||
status text not null default 'open' check (status in ('open', 'resolved')),
|
||||
resolution text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
type text not null,
|
||||
payload jsonb not null,
|
||||
status text not null default 'pending' check (status in ('pending', 'processing', 'done', 'failed')),
|
||||
attempts integer not null default 0,
|
||||
last_error text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_maker_profiles_status on maker_profiles(status);
|
||||
create index if not exists idx_services_maker_id on services(maker_id);
|
||||
create index if not exists idx_works_maker_id on works(maker_id);
|
||||
create index if not exists idx_showcases_maker_id on showcases(maker_id);
|
||||
create index if not exists idx_maker_locations_maker_id on maker_locations(maker_id);
|
||||
create index if not exists idx_inquiries_maker_id on inquiries(maker_id);
|
||||
create index if not exists idx_messages_inquiry_id on messages(inquiry_id);
|
||||
create index if not exists idx_favorite_makers_user_id on favorite_makers(user_id);
|
||||
create index if not exists idx_favorite_works_user_id on favorite_works(user_id);
|
||||
create index if not exists idx_jobs_status on jobs(status);
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildPublishChecklist, isPublishReady } from "@makers3d/shared";
|
||||
|
||||
test("publish checklist exige suscripcion activa", () => {
|
||||
const checklist = buildPublishChecklist({
|
||||
businessName: "Maker",
|
||||
description: "Descripcion suficiente",
|
||||
province: "CABA",
|
||||
city: "Buenos Aires",
|
||||
servicesCount: 1,
|
||||
worksCount: 1,
|
||||
publicContactEmail: "maker@test.com",
|
||||
subscriptionStatus: "inactive"
|
||||
});
|
||||
|
||||
assert.equal(isPublishReady(checklist), false);
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import { buildPublishChecklist, isPublishReady, maskCoordinates, slugify, type Availability, type SubscriptionStatus } from "@makers3d/shared";
|
||||
|
||||
import { query, one } from "./db.js";
|
||||
|
||||
type MakerRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
slug: string;
|
||||
business_name: string | null;
|
||||
description: string | null;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
delivery_scope: string | null;
|
||||
availability: Availability;
|
||||
public_contact_email: string | null;
|
||||
public_whatsapp: string | null;
|
||||
website_url: string | null;
|
||||
instagram_url: string | null;
|
||||
tiktok_url: string | null;
|
||||
twitter_url: string | null;
|
||||
facebook_url: string | null;
|
||||
youtube_url: string | null;
|
||||
linkedin_url: string | null;
|
||||
allow_verified_reviews: boolean;
|
||||
show_satisfaction_score: boolean;
|
||||
show_review_tags: boolean;
|
||||
allow_maker_review_reply: boolean;
|
||||
show_past_reviews: boolean;
|
||||
status: string;
|
||||
main_image_url: string | null;
|
||||
};
|
||||
|
||||
export async function getMakerByUserId(userId: string) {
|
||||
return one<MakerRow>(`select * from maker_profiles where user_id = $1`, [userId]);
|
||||
}
|
||||
|
||||
export async function getMakerBySlug(slug: string) {
|
||||
return one<MakerRow>(
|
||||
`select * from maker_profiles where slug = $1 and status in ('draft', 'published', 'suspended')`,
|
||||
[slug]
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureMakerProfile(userId: string): Promise<MakerRow> {
|
||||
let profile = await getMakerByUserId(userId);
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const slug = `maker-${userId.slice(0, 8)}`;
|
||||
const rows = await query<MakerRow>(
|
||||
`
|
||||
insert into maker_profiles (
|
||||
user_id, slug, availability, delivery_scope, status
|
||||
) values ($1, $2, 'available', 'local', 'draft')
|
||||
returning *
|
||||
`,
|
||||
[userId, slug]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getSubscriptionStatus(makerId: string): Promise<SubscriptionStatus> {
|
||||
const result = await one<{ status: SubscriptionStatus }>(
|
||||
`select status from subscriptions where maker_id = $1 order by created_at desc limit 1`,
|
||||
[makerId]
|
||||
);
|
||||
return result?.status ?? "inactive";
|
||||
}
|
||||
|
||||
export async function upsertMakerProfile(userId: string, input: {
|
||||
businessName: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
deliveryScope: string;
|
||||
availability: Availability;
|
||||
publicContactEmail?: string;
|
||||
publicWhatsapp?: string;
|
||||
mainImageUrl?: string;
|
||||
websiteUrl?: string;
|
||||
instagramUrl?: string;
|
||||
tiktokUrl?: string;
|
||||
twitterUrl?: string;
|
||||
facebookUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
linkedinUrl?: string;
|
||||
}) {
|
||||
const existing = await ensureMakerProfile(userId);
|
||||
const slugBase = slugify(input.businessName || existing.slug || "maker");
|
||||
const masked = maskCoordinates(input.latitude, input.longitude);
|
||||
const rows = await query<MakerRow>(
|
||||
`
|
||||
update maker_profiles
|
||||
set slug = $2,
|
||||
business_name = $3,
|
||||
description = $4,
|
||||
province = $5,
|
||||
city = $6,
|
||||
latitude = $7,
|
||||
longitude = $8,
|
||||
public_latitude = $9,
|
||||
public_longitude = $10,
|
||||
delivery_scope = $11,
|
||||
availability = $12,
|
||||
public_contact_email = $13,
|
||||
public_whatsapp = $14,
|
||||
main_image_url = coalesce($15, main_image_url),
|
||||
website_url = $16,
|
||||
instagram_url = $17,
|
||||
tiktok_url = $18,
|
||||
twitter_url = $19,
|
||||
facebook_url = $20,
|
||||
youtube_url = $21,
|
||||
linkedin_url = $22,
|
||||
updated_at = now()
|
||||
where user_id = $1
|
||||
returning *
|
||||
`,
|
||||
[
|
||||
userId,
|
||||
`${slugBase}-${existing.id.slice(0, 4)}`,
|
||||
input.businessName,
|
||||
input.description,
|
||||
input.province,
|
||||
input.city,
|
||||
input.latitude,
|
||||
input.longitude,
|
||||
masked.latitude,
|
||||
masked.longitude,
|
||||
input.deliveryScope,
|
||||
input.availability,
|
||||
input.publicContactEmail || null,
|
||||
input.publicWhatsapp || null,
|
||||
input.mainImageUrl || null,
|
||||
input.websiteUrl || null,
|
||||
input.instagramUrl || null,
|
||||
input.tiktokUrl || null,
|
||||
input.twitterUrl || null,
|
||||
input.facebookUrl || null,
|
||||
input.youtubeUrl || null,
|
||||
input.linkedinUrl || null
|
||||
]
|
||||
);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getMakerChecklist(makerId: string) {
|
||||
const maker = await one<MakerRow>(`select * from maker_profiles where id = $1`, [makerId]);
|
||||
if (!maker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const services = await one<{ count: string }>(`select count(*) from services where maker_id = $1 and is_published = true`, [makerId]);
|
||||
const works = await one<{ count: string }>(`select count(*) from works where maker_id = $1 and is_published = true`, [makerId]);
|
||||
const subscriptionStatus = await getSubscriptionStatus(makerId);
|
||||
|
||||
const checklist = buildPublishChecklist({
|
||||
businessName: maker.business_name,
|
||||
description: maker.description,
|
||||
province: maker.province,
|
||||
city: maker.city,
|
||||
servicesCount: Number(services?.count ?? 0),
|
||||
worksCount: Number(works?.count ?? 0),
|
||||
publicContactEmail: maker.public_contact_email,
|
||||
publicWhatsapp: maker.public_whatsapp,
|
||||
subscriptionStatus
|
||||
});
|
||||
|
||||
return {
|
||||
checklist,
|
||||
ready: isPublishReady(checklist),
|
||||
subscriptionStatus
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishMakerIfReady(makerId: string) {
|
||||
const checklistInfo = await getMakerChecklist(makerId);
|
||||
if (!checklistInfo || !checklistInfo.ready) {
|
||||
return { published: false, reason: "Checklist incomplete" };
|
||||
}
|
||||
|
||||
await query(`update maker_profiles set status = 'published', updated_at = now() where id = $1`, [makerId]);
|
||||
return { published: true };
|
||||
}
|
||||
|
||||
export async function listPublicMakers(filters: {
|
||||
q?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
serviceCategory?: string;
|
||||
material?: string;
|
||||
deliveryScope?: string;
|
||||
}) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [`m.status = 'published'`];
|
||||
let queryExactIndex = 0;
|
||||
let queryLikeIndex = 0;
|
||||
|
||||
if (filters.q) {
|
||||
params.push(filters.q.toLowerCase());
|
||||
queryExactIndex = params.length;
|
||||
params.push(`%${filters.q.toLowerCase()}%`);
|
||||
queryLikeIndex = params.length;
|
||||
conditions.push(`(
|
||||
lower(m.business_name) like $${queryLikeIndex}
|
||||
or lower(m.description) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and (
|
||||
lower(s.title) like $${queryLikeIndex}
|
||||
or lower(s.description) like $${queryLikeIndex}
|
||||
or lower(s.category) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.technologies) technology
|
||||
where lower(technology) like $${queryLikeIndex}
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.materials) material
|
||||
where lower(material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
)
|
||||
or exists (
|
||||
select 1 from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and (
|
||||
lower(w.title) like $${queryLikeIndex}
|
||||
or lower(w.summary) like $${queryLikeIndex}
|
||||
or lower(w.story) like $${queryLikeIndex}
|
||||
or lower(w.technology) like $${queryLikeIndex}
|
||||
or lower(w.material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
)`);
|
||||
}
|
||||
|
||||
if (filters.province) {
|
||||
params.push(filters.province);
|
||||
conditions.push(`m.province = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.city) {
|
||||
params.push(filters.city);
|
||||
conditions.push(`m.city = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.deliveryScope) {
|
||||
params.push(filters.deliveryScope);
|
||||
conditions.push(`m.delivery_scope = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.serviceCategory) {
|
||||
params.push(filters.serviceCategory);
|
||||
conditions.push(`exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and s.category = $${params.length}
|
||||
)`);
|
||||
}
|
||||
|
||||
if (filters.material) {
|
||||
params.push(filters.material);
|
||||
conditions.push(`exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and $${params.length} = any(s.materials)
|
||||
)`);
|
||||
}
|
||||
|
||||
const matchScoreSelect = filters.q ? `
|
||||
(
|
||||
case when lower(m.business_name) = $${queryExactIndex} then 160 else 0 end +
|
||||
case when lower(m.business_name) like $${queryLikeIndex} then 90 else 0 end +
|
||||
case when lower(m.description) like $${queryLikeIndex} then 36 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and lower(s.title) = $${queryExactIndex}
|
||||
) then 140 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and lower(s.title) like $${queryLikeIndex}
|
||||
) then 96 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and (
|
||||
lower(s.description) like $${queryLikeIndex}
|
||||
or lower(s.category) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.technologies) technology
|
||||
where lower(technology) like $${queryLikeIndex}
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.materials) material
|
||||
where lower(material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
) then 54 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and lower(w.title) = $${queryExactIndex}
|
||||
) then 150 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and lower(w.title) like $${queryLikeIndex}
|
||||
) then 102 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and (
|
||||
lower(w.summary) like $${queryLikeIndex}
|
||||
or lower(w.story) like $${queryLikeIndex}
|
||||
or lower(w.technology) like $${queryLikeIndex}
|
||||
or lower(w.material) like $${queryLikeIndex}
|
||||
)
|
||||
) then 66 else 0 end
|
||||
) as match_score,
|
||||
` : `
|
||||
0 as match_score,
|
||||
`;
|
||||
|
||||
const orderBy = filters.q
|
||||
? `match_score desc, review_count desc, m.business_name asc`
|
||||
: `review_count desc, m.business_name asc`;
|
||||
|
||||
return query(
|
||||
`
|
||||
select
|
||||
m.id,
|
||||
m.slug,
|
||||
m.business_name,
|
||||
m.description,
|
||||
m.province,
|
||||
m.city,
|
||||
m.public_latitude,
|
||||
m.public_longitude,
|
||||
m.delivery_scope,
|
||||
m.availability,
|
||||
m.main_image_url,
|
||||
coalesce(
|
||||
lv.public_locations,
|
||||
case
|
||||
when m.public_latitude is not null and m.public_longitude is not null then jsonb_build_array(jsonb_build_object(
|
||||
'id', m.id,
|
||||
'label', m.business_name,
|
||||
'city', m.city,
|
||||
'province', m.province,
|
||||
'latitude', m.public_latitude,
|
||||
'longitude', m.public_longitude
|
||||
))
|
||||
else '[]'::jsonb
|
||||
end
|
||||
) as public_locations,
|
||||
coalesce(rv.avg_rating, 0) as avg_rating,
|
||||
coalesce(rv.review_count, 0) as review_count,
|
||||
coalesce(sv.service_count, 0) as service_count,
|
||||
coalesce(wv.work_count, 0) as work_count,
|
||||
${matchScoreSelect}
|
||||
coalesce(cv.categories, '{}'::text[]) as categories
|
||||
from maker_profiles m
|
||||
left join (
|
||||
select maker_id, round(avg(rating_overall)::numeric, 1) as avg_rating, count(*) as review_count
|
||||
from reviews
|
||||
where status = 'published'
|
||||
group by maker_id
|
||||
) rv on rv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, count(*) as service_count
|
||||
from services
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) sv on sv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, count(*) as work_count
|
||||
from works
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) wv on wv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, array_agg(distinct category order by category) as categories
|
||||
from services
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) cv on cv.maker_id = m.id
|
||||
left join (
|
||||
select
|
||||
maker_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'label', label,
|
||||
'city', city,
|
||||
'province', province,
|
||||
'latitude', case when privacy = 'exact' then latitude else round(latitude::numeric, 2)::double precision end,
|
||||
'longitude', case when privacy = 'exact' then longitude else round(longitude::numeric, 2)::double precision end
|
||||
)
|
||||
order by created_at desc
|
||||
) as public_locations
|
||||
from maker_locations
|
||||
where is_published = true
|
||||
and latitude is not null
|
||||
and longitude is not null
|
||||
group by maker_id
|
||||
) lv on lv.maker_id = m.id
|
||||
where ${conditions.join(" and ")}
|
||||
order by ${orderBy}
|
||||
`,
|
||||
params
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaCandidates = [
|
||||
join(currentDir, "../db/schema.sql"),
|
||||
join(process.cwd(), "apps/api/src/db/schema.sql"),
|
||||
join(process.cwd(), "dist/apps/api/src/db/schema.sql")
|
||||
];
|
||||
|
||||
let sql: string | undefined;
|
||||
|
||||
for (const schemaPath of schemaCandidates) {
|
||||
try {
|
||||
sql = await readFile(schemaPath, "utf8");
|
||||
break;
|
||||
} catch {
|
||||
// Try the next known location. The runtime container keeps the schema outside dist.
|
||||
}
|
||||
}
|
||||
|
||||
if (!sql) {
|
||||
throw new Error(`Schema file not found. Checked: ${schemaCandidates.join(", ")}`);
|
||||
}
|
||||
|
||||
await pool.query(sql);
|
||||
console.log("Database schema applied");
|
||||
await pool.end();
|
||||
@@ -0,0 +1,403 @@
|
||||
import { slugify } from "@makers3d/shared";
|
||||
|
||||
import { hashPassword } from "../auth.js";
|
||||
import { pool, query, one } from "../db.js";
|
||||
import { getMakerByUserId, publishMakerIfReady, upsertMakerProfile } from "../queries.js";
|
||||
|
||||
type DemoMaker = {
|
||||
businessName: string;
|
||||
category: "Impresion FDM" | "Impresion Resina" | "Diseno 3D" | "Prototipado";
|
||||
focus: string;
|
||||
imageUrl: string;
|
||||
material: string;
|
||||
technology: string;
|
||||
priceFromCents: number;
|
||||
deliveryScope: "local" | "nationwide";
|
||||
availability: "available" | "limited";
|
||||
reviewScore: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
|
||||
async function ensureUser(email: string, password: string, fullName: string, role: "user" | "admin" = "user") {
|
||||
const existing = await one<{ id: string }>(`select id from users where email = $1`, [email]);
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
insert into users (email, password_hash, full_name, role)
|
||||
values ($1, $2, $3, $4)
|
||||
returning id
|
||||
`,
|
||||
[email, passwordHash, fullName, role]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async function ensureActiveSubscription(makerId: string) {
|
||||
await query(
|
||||
`
|
||||
insert into subscriptions (maker_id, provider, plan_code, status, activated_at, expires_at)
|
||||
select $1, 'demo', 'maker-base', 'active', now(), now() + interval '30 days'
|
||||
where not exists (
|
||||
select 1
|
||||
from subscriptions
|
||||
where maker_id = $1
|
||||
and provider = 'demo'
|
||||
and plan_code = 'maker-base'
|
||||
)
|
||||
`,
|
||||
[makerId]
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertService(makerId: string, slug: string, maker: DemoMaker) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
insert into services (
|
||||
maker_id, slug, title, category, description, technologies, materials, local_pickup, nationwide_shipping, lead_time_days, price_from_cents, is_published
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $10, true)
|
||||
on conflict (slug) do update
|
||||
set title = excluded.title,
|
||||
category = excluded.category,
|
||||
description = excluded.description,
|
||||
technologies = excluded.technologies,
|
||||
materials = excluded.materials,
|
||||
nationwide_shipping = excluded.nationwide_shipping,
|
||||
lead_time_days = excluded.lead_time_days,
|
||||
price_from_cents = excluded.price_from_cents,
|
||||
is_published = true,
|
||||
updated_at = now()
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
makerId,
|
||||
slug,
|
||||
`${maker.category} para ${maker.focus}`,
|
||||
maker.category,
|
||||
`${maker.businessName} resuelve ${maker.focus} con ${maker.technology} y materiales como ${maker.material}.`,
|
||||
[maker.technology, maker.category === "Diseno 3D" ? "CAD" : "Diseno 3D"],
|
||||
[maker.material, "PLA", "PETG"],
|
||||
maker.deliveryScope === "nationwide",
|
||||
maker.category === "Impresion Resina" ? 4 : 3,
|
||||
maker.priceFromCents
|
||||
]
|
||||
);
|
||||
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async function upsertWork(makerId: string, serviceId: string, slug: string, maker: DemoMaker, index: number) {
|
||||
const workVariantByImage: Record<string, { title: string; summaryTopic: string; storyTopic: string }> = {
|
||||
"/demo/work-coffee-hinge.svg": {
|
||||
title: "Restauracion de engranaje",
|
||||
summaryTopic: "engranaje funcional para cafetera y repuesto mecanico",
|
||||
storyTopic: "una pieza de transmision y recuperacion de movimiento"
|
||||
},
|
||||
"/demo/work-custom.svg": {
|
||||
title: "Soporte funcional",
|
||||
summaryTopic: "soporte tecnico para montaje, fijacion o uso real",
|
||||
storyTopic: "un soporte utilitario para montaje y ajuste"
|
||||
},
|
||||
"/demo/work-dashboard-bracket.svg": {
|
||||
title: "Tablero y carcasa tecnica",
|
||||
summaryTopic: "tablero de control, panel tecnico y carcasa estructural",
|
||||
storyTopic: "un componente de tablero, panel o control tecnico"
|
||||
},
|
||||
"/demo/maker-hero-1.svg": {
|
||||
title: "Prototipo validado",
|
||||
summaryTopic: "prototipo funcional, maqueta y validacion de producto",
|
||||
storyTopic: "una prueba de concepto y validacion de producto"
|
||||
}
|
||||
};
|
||||
const variant = workVariantByImage[maker.imageUrl] || workVariantByImage["/demo/maker-hero-1.svg"];
|
||||
const workTitle = `${variant.title} ${maker.businessName}`;
|
||||
|
||||
await query(
|
||||
`
|
||||
insert into works (
|
||||
maker_id, service_id, slug, title, summary, story, technology, material, image_url, gallery_urls, is_published
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||
on conflict (slug) do update
|
||||
set title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
story = excluded.story,
|
||||
technology = excluded.technology,
|
||||
material = excluded.material,
|
||||
image_url = excluded.image_url,
|
||||
gallery_urls = excluded.gallery_urls,
|
||||
is_published = true,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
makerId,
|
||||
serviceId,
|
||||
slug,
|
||||
workTitle,
|
||||
`${variant.summaryTopic} con enfoque en ${maker.material}, ${maker.technology} y entrega clara.`,
|
||||
`${maker.businessName} publico este caso como demostracion de ${maker.focus}. Se documento ${variant.storyTopic}, el problema, la solucion y el material utilizado para facilitar comparacion y contacto.`,
|
||||
maker.technology,
|
||||
maker.material,
|
||||
maker.imageUrl,
|
||||
[maker.imageUrl]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureInquiryAndReview(makerId: string, makerUserId: string, maker: DemoMaker, customerId: string, reviewIndex: number) {
|
||||
const needText = `Consulta demo ${reviewIndex + 1} para ${maker.businessName}`;
|
||||
let inquiry = await one<{ id: string }>(
|
||||
`select id from inquiries where maker_id = $1 and customer_id = $2 and need_text = $3`,
|
||||
[makerId, customerId, needText]
|
||||
);
|
||||
|
||||
if (!inquiry) {
|
||||
const created = await query<{ id: string }>(
|
||||
`
|
||||
insert into inquiries (
|
||||
maker_id, customer_id, source_type, need_text, size_text, has_model, material_preference, quantity, urgency, delivery_type, status
|
||||
)
|
||||
values ($1, $2, 'work', $3, '12 cm', true, $4, 1, 'medium', 'shipping', 'completed')
|
||||
returning id
|
||||
`,
|
||||
[makerId, customerId, needText, maker.material]
|
||||
);
|
||||
inquiry = created[0];
|
||||
|
||||
await query(
|
||||
`
|
||||
insert into messages (inquiry_id, sender_user_id, body)
|
||||
values
|
||||
($1, $2, 'Hola, quiero algo similar a lo publicado.'),
|
||||
($1, $3, 'Perfecto, te puedo ayudar con ese trabajo.'),
|
||||
($1, $2, 'Buenisimo, avancemos con medidas y entrega.')
|
||||
`,
|
||||
[inquiry.id, customerId, makerUserId]
|
||||
);
|
||||
}
|
||||
|
||||
const roundedRating = Math.max(4, Math.round(maker.reviewScore));
|
||||
await query(
|
||||
`
|
||||
insert into reviews (
|
||||
inquiry_id, maker_id, customer_id, rating_overall, rating_quality, rating_communication, rating_value, comment, status
|
||||
)
|
||||
values ($1, $2, $3, $4, $4, $4, $5, $6, 'published')
|
||||
on conflict (inquiry_id) do update
|
||||
set rating_overall = excluded.rating_overall,
|
||||
rating_quality = excluded.rating_quality,
|
||||
rating_communication = excluded.rating_communication,
|
||||
rating_value = excluded.rating_value,
|
||||
comment = excluded.comment,
|
||||
status = 'published'
|
||||
`,
|
||||
[
|
||||
inquiry.id,
|
||||
makerId,
|
||||
customerId,
|
||||
roundedRating,
|
||||
Math.max(4, roundedRating - 1),
|
||||
`${maker.businessName} respondio bien y entrego una solucion convincente para ${maker.focus}.`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const adminId = await ensureUser("admin@makers3d.local", "Admin123!", "Admin", "admin");
|
||||
const clientIds = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
ensureUser(`cliente${index + 1}@makers3d.local`, "Cliente123!", `Cliente Demo ${index + 1}`)
|
||||
)
|
||||
);
|
||||
|
||||
const makers: DemoMaker[] = [
|
||||
{
|
||||
businessName: "MakerLab 3D",
|
||||
category: "Impresion FDM",
|
||||
focus: "repuestos funcionales, ingenieria y piezas de uso real",
|
||||
imageUrl: "/demo/work-coffee-hinge.svg",
|
||||
material: "PETG",
|
||||
technology: "FDM",
|
||||
priceFromCents: 150000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.9,
|
||||
reviewCount: 4
|
||||
},
|
||||
{
|
||||
businessName: "PrintCraft",
|
||||
category: "Impresion FDM",
|
||||
focus: "series cortas, soportes tecnicos y repuestos industriales",
|
||||
imageUrl: "/demo/work-custom.svg",
|
||||
material: "ABS",
|
||||
technology: "FDM",
|
||||
priceFromCents: 165000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.8,
|
||||
reviewCount: 3
|
||||
},
|
||||
{
|
||||
businessName: "3D Ideas",
|
||||
category: "Diseno 3D",
|
||||
focus: "prototipos, modelado y piezas personalizadas",
|
||||
imageUrl: "/demo/maker-hero-custom.svg",
|
||||
material: "PLA",
|
||||
technology: "CAD",
|
||||
priceFromCents: 180000,
|
||||
deliveryScope: "local",
|
||||
availability: "available",
|
||||
reviewScore: 4.7,
|
||||
reviewCount: 2
|
||||
},
|
||||
{
|
||||
businessName: "ImpresionAR",
|
||||
category: "Impresion Resina",
|
||||
focus: "figuras detalladas, miniaturas y piezas en resina",
|
||||
imageUrl: "/demo/work-dashboard-bracket.svg",
|
||||
material: "Resina estandar",
|
||||
technology: "Resina",
|
||||
priceFromCents: 175000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.8,
|
||||
reviewCount: 2
|
||||
},
|
||||
{
|
||||
businessName: "ProtoWorks",
|
||||
category: "Prototipado",
|
||||
focus: "validacion de producto, prototipos funcionales y ajustes rapidos",
|
||||
imageUrl: "/demo/work-custom.svg",
|
||||
material: "PLA",
|
||||
technology: "FDM",
|
||||
priceFromCents: 210000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "limited",
|
||||
reviewScore: 4.6,
|
||||
reviewCount: 1
|
||||
},
|
||||
{
|
||||
businessName: "Resin Forge",
|
||||
category: "Impresion Resina",
|
||||
focus: "piezas finas, moldes chicos y detalle visual en resina",
|
||||
imageUrl: "/demo/work-dashboard-bracket.svg",
|
||||
material: "Resina alta definicion",
|
||||
technology: "Resina",
|
||||
priceFromCents: 195000,
|
||||
deliveryScope: "local",
|
||||
availability: "available",
|
||||
reviewScore: 4.9,
|
||||
reviewCount: 1
|
||||
},
|
||||
...[
|
||||
"RepuestoLab",
|
||||
"CadSur Studio",
|
||||
"Volumen 3D",
|
||||
"Nodo Maker",
|
||||
"Delta Print",
|
||||
"Origen 3D",
|
||||
"Atlas Makers",
|
||||
"Prisma Resina",
|
||||
"FabCordoba",
|
||||
"Punto Layer",
|
||||
"Taller Capa",
|
||||
"Linea 3D",
|
||||
"ProtoRio",
|
||||
"Hexa Print",
|
||||
"Monoblock 3D",
|
||||
"Cubo Norte",
|
||||
"Rayo Maker",
|
||||
"Ruta FDM",
|
||||
"Andes Prototype",
|
||||
"Sur Layers",
|
||||
"Pixel Resina",
|
||||
"Brio 3D",
|
||||
"Arco Maker",
|
||||
"Nexa CAD",
|
||||
"Torno 3D",
|
||||
"Faro Print",
|
||||
"Cima Resina",
|
||||
"Plano 3D",
|
||||
"Malla Lab",
|
||||
"Vector Maker"
|
||||
].map<DemoMaker>((businessName, index) => {
|
||||
const categoryCycle = [
|
||||
"Impresion FDM",
|
||||
"Diseno 3D",
|
||||
"Impresion Resina",
|
||||
"Prototipado"
|
||||
] as const;
|
||||
const imageCycle = [
|
||||
"/demo/work-coffee-hinge.svg",
|
||||
"/demo/work-custom.svg",
|
||||
"/demo/work-dashboard-bracket.svg",
|
||||
"/demo/maker-hero-1.svg"
|
||||
];
|
||||
const focusCycle = [
|
||||
"repuestos, soportes tecnicos y piezas funcionales",
|
||||
"modelado, diseno 3D y preparacion de archivos",
|
||||
"resina, detalle fino y miniaturas",
|
||||
"prototipos, validacion y ajustes de producto"
|
||||
];
|
||||
const materialCycle = ["PETG", "PLA", "Resina estandar", "ABS"] as const;
|
||||
const technologyCycle = ["FDM", "CAD", "Resina", "FDM"] as const;
|
||||
|
||||
return {
|
||||
businessName,
|
||||
category: categoryCycle[index % categoryCycle.length],
|
||||
focus: focusCycle[index % focusCycle.length],
|
||||
imageUrl: imageCycle[index % imageCycle.length],
|
||||
material: materialCycle[index % materialCycle.length],
|
||||
technology: technologyCycle[index % technologyCycle.length],
|
||||
priceFromCents: 145000 + index * 5000,
|
||||
deliveryScope: index % 3 === 0 ? "nationwide" : "local",
|
||||
availability: index % 5 === 0 ? "limited" : "available",
|
||||
reviewScore: 4.4 + (index % 5) * 0.1,
|
||||
reviewCount: index % 3
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
for (const [index, makerSpec] of makers.entries()) {
|
||||
const makerEmail = `maker${index + 1}@makers3d.local`;
|
||||
const makerUserId = await ensureUser(makerEmail, "Maker123!", makerSpec.businessName);
|
||||
const latitude = -31.4201 + (index % 6) * 0.009;
|
||||
const longitude = -64.1888 + (index % 5) * 0.011;
|
||||
|
||||
await upsertMakerProfile(makerUserId, {
|
||||
businessName: makerSpec.businessName,
|
||||
description: `${makerSpec.businessName} ofrece ${makerSpec.focus} en Cordoba, con foco en ${makerSpec.category.toLowerCase()}.`,
|
||||
province: "Cordoba",
|
||||
city: "Cordoba",
|
||||
latitude,
|
||||
longitude,
|
||||
deliveryScope: makerSpec.deliveryScope,
|
||||
availability: makerSpec.availability,
|
||||
publicContactEmail: makerEmail,
|
||||
publicWhatsapp: `+5493510000${String(index + 1).padStart(3, "0")}`,
|
||||
mainImageUrl: makerSpec.imageUrl
|
||||
});
|
||||
|
||||
const makerProfile = await getMakerByUserId(makerUserId);
|
||||
if (!makerProfile) {
|
||||
throw new Error(`Maker profile missing for ${makerSpec.businessName}`);
|
||||
}
|
||||
|
||||
await ensureActiveSubscription(makerProfile.id);
|
||||
|
||||
const baseSlug = slugify(makerSpec.businessName);
|
||||
const serviceId = await upsertService(makerProfile.id, `${baseSlug}-servicio-demo`, makerSpec);
|
||||
await upsertWork(makerProfile.id, serviceId, `${baseSlug}-trabajo-demo`, makerSpec, index);
|
||||
await publishMakerIfReady(makerProfile.id);
|
||||
|
||||
for (let reviewIndex = 0; reviewIndex < makerSpec.reviewCount; reviewIndex += 1) {
|
||||
const customerId = clientIds[(index + reviewIndex) % clientIds.length];
|
||||
await ensureInquiryAndReview(makerProfile.id, makerUserId, makerSpec, customerId, reviewIndex);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Seed completed. Admin=${adminId} Makers=${makers.length}`);
|
||||
await pool.end();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Client } from "minio";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const storage = new Client({
|
||||
endPoint: config.MINIO_ENDPOINT,
|
||||
port: config.MINIO_PORT,
|
||||
useSSL: config.MINIO_USE_SSL,
|
||||
accessKey: config.MINIO_ROOT_USER,
|
||||
secretKey: config.MINIO_ROOT_PASSWORD
|
||||
});
|
||||
|
||||
export async function ensureBuckets(): Promise<void> {
|
||||
for (const bucket of [config.MINIO_PUBLIC_BUCKET, config.MINIO_PRIVATE_BUCKET]) {
|
||||
const exists = await storage.bucketExists(bucket).catch(() => false);
|
||||
if (!exists) {
|
||||
await storage.makeBucket(bucket, "us-east-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPublicObject(fileName: string, contentType: string, buffer: Buffer): Promise<string> {
|
||||
const objectName = `${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9.\-_]/g, "-")}`;
|
||||
await storage.putObject(config.MINIO_PUBLIC_BUCKET, objectName, buffer, buffer.length, {
|
||||
"Content-Type": contentType
|
||||
});
|
||||
return objectName;
|
||||
}
|
||||
|
||||
export async function getPublicObject(objectName: string) {
|
||||
return storage.getObject(config.MINIO_PUBLIC_BUCKET, objectName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build -w @makers3d/web
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||
COPY --from=build /app/apps/web/next.config.ts ./apps/web/next.config.ts
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node_modules/.bin/next", "start", "apps/web", "-p", "3000"]
|
||||
@@ -0,0 +1,15 @@
|
||||
import AccountClient from "../../../components/AccountClient";
|
||||
|
||||
export default async function AccountInboxDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="inbox" conversationId={id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountInboxPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="inbox" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountLocationsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="locations" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../components/AccountClient";
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="summary" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountProfilePage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="profile" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountReviewsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="reviews" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountServicesPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="services" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="settings" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountShowcasesPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="showcases" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountStatsPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="stats" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountSubscriptionPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="subscription" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AccountClient from "../../components/AccountClient";
|
||||
|
||||
export default function AccountWorksPage() {
|
||||
return (
|
||||
<main className="page-shell workspace-page">
|
||||
<AccountClient section="works" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import BackofficeEntryClient from "../../../components/BackofficeEntryClient";
|
||||
import type { AdminSection } from "../../../components/AdminClient";
|
||||
|
||||
const sectionMap: Record<string, AdminSection> = {
|
||||
queue: "queue",
|
||||
clients: "clients",
|
||||
makers: "makers",
|
||||
cases: "incidents",
|
||||
"internal-users": "internal-users",
|
||||
reviews: "reviews",
|
||||
incidents: "incidents",
|
||||
reports: "reports",
|
||||
fraud: "fraud",
|
||||
appeals: "appeals",
|
||||
subscriptions: "subscriptions",
|
||||
payments: "payments",
|
||||
billing: "billing",
|
||||
promotions: "promotions",
|
||||
founders: "founders",
|
||||
works: "works",
|
||||
showcases: "showcases",
|
||||
services: "services",
|
||||
"reported-media": "reported-media",
|
||||
"catalog-services": "catalog-services",
|
||||
categories: "categories",
|
||||
materials: "materials",
|
||||
technologies: "technologies",
|
||||
specialties: "specialties",
|
||||
locations: "locations",
|
||||
tickets: "tickets",
|
||||
conversations: "conversations",
|
||||
"help-center": "help-center",
|
||||
"analytics-product": "analytics-product",
|
||||
"analytics-makers": "analytics-makers",
|
||||
"analytics-clients": "analytics-clients",
|
||||
"analytics-trust": "analytics-trust",
|
||||
"analytics-business": "analytics-business",
|
||||
roles: "roles",
|
||||
settings: "settings",
|
||||
integrations: "integrations",
|
||||
templates: "templates",
|
||||
audit: "audit",
|
||||
system: "system"
|
||||
};
|
||||
|
||||
export default async function BackofficePage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ section?: string[] }>;
|
||||
}) {
|
||||
const resolvedParams = await params;
|
||||
const currentSection = resolvedParams.section?.[0];
|
||||
const section = currentSection ? sectionMap[currentSection] || "overview" : "overview";
|
||||
|
||||
return <BackofficeEntryClient section={section} />;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { logoutByNavigation } from "../lib/logout";
|
||||
|
||||
type MeResponse = {
|
||||
user: { id: string; email: string; role: string } | null;
|
||||
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
|
||||
};
|
||||
|
||||
export default function AuthNav() {
|
||||
const [session, setSession] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const response = await browserFetch<MeResponse>("/auth/me");
|
||||
if (active) {
|
||||
setSession(response);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setSession({ user: null, makerProfile: null });
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
logoutByNavigation();
|
||||
}
|
||||
|
||||
const user = session?.user || null;
|
||||
const makerProfile = session?.makerProfile || null;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isMaker = Boolean(makerProfile && makerProfile.status !== "draft");
|
||||
|
||||
return (
|
||||
<nav className="nav-links">
|
||||
<Link href="/" className="nav-link">Inicio</Link>
|
||||
<Link href="/discover" className="nav-link">Descubrir</Link>
|
||||
|
||||
{loading ? <span className="nav-session-chip">Sesion...</span> : null}
|
||||
|
||||
{!loading && !user ? (
|
||||
<Link href="/login" className="button button-primary">Entrar</Link>
|
||||
) : null}
|
||||
|
||||
{!loading && user && isAdmin ? (
|
||||
<>
|
||||
<a href="/app/backoffice" className="nav-link nav-link-admin">Backoffice</a>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user && !isAdmin && isMaker ? (
|
||||
<>
|
||||
<Link href="/account" className="nav-link">Mi espacio maker</Link>
|
||||
<Link href="/account/inbox" className="nav-link">Consultas maker</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user && !isAdmin && !isMaker ? (
|
||||
<>
|
||||
<Link href="/messages" className="nav-link">Mensajes</Link>
|
||||
<Link href="/favorites" className="nav-link">Favoritos</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && user ? (
|
||||
<button className="button button-secondary nav-logout-button" type="button" onClick={logout}>
|
||||
Cerrar sesion
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { listenAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function AuthSessionSync() {
|
||||
useEffect(() => listenAuthChanged(() => {
|
||||
window.location.reload();
|
||||
}), []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import AdminClient, { type AdminSection } from "./AdminClient";
|
||||
|
||||
export default function BackofficeEntryClient({ section }: { section: AdminSection }) {
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<"loading" | "ready" | "forbidden">("loading");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const response = await browserFetch<{ user: { id: string; role: string } | null }>("/auth/me");
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.user) {
|
||||
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.user.role !== "admin") {
|
||||
setState("forbidden");
|
||||
setError("Esta cuenta no tiene permisos de backoffice.");
|
||||
return;
|
||||
}
|
||||
|
||||
setState("ready");
|
||||
} catch (sessionError) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = sessionError instanceof Error ? sessionError.message : "No se pudo validar la sesion";
|
||||
setError(message);
|
||||
window.location.replace(`/login?returnTo=${encodeURIComponent(pathname || "/app/backoffice")}`);
|
||||
}
|
||||
}
|
||||
|
||||
void checkSession();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<section className="panel stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Validando acceso</h1>
|
||||
<p className="muted">Comprobando la sesion antes de abrir el panel operativo.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "forbidden") {
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<section className="panel stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Acceso restringido</h1>
|
||||
<p className="muted">{error}</p>
|
||||
<div className="home-actions">
|
||||
<Link href="/" className="button button-primary">Volver a la app</Link>
|
||||
<Link href="/login?returnTo=%2Fapp%2Fbackoffice" className="button button-secondary">Entrar con otra cuenta</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-shell ops-page">
|
||||
<AdminClient section={section} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import FavoriteToggle from "./FavoriteToggle";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
};
|
||||
|
||||
type PublicWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string | null;
|
||||
gallery_urls: string[] | null;
|
||||
maker_id: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
|
||||
|
||||
const discoverReturnKey = "makers3d:discover:return-position";
|
||||
|
||||
function getRating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function workImages(work: PublicWork) {
|
||||
const images = [work.image_url, ...(work.gallery_urls || [])]
|
||||
.filter((image): image is string => Boolean(image));
|
||||
return Array.from(new Set(images)).slice(0, 8);
|
||||
}
|
||||
|
||||
function getWorkTags(work: PublicWork) {
|
||||
return [work.technology, work.material].filter(Boolean).slice(0, 3);
|
||||
}
|
||||
|
||||
function matchesCategory(work: PublicWork, category: DiscoverCategory) {
|
||||
if (category === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [work.title, work.summary, work.technology, work.material, work.maker_name]
|
||||
.join(" ")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
if (category === "fdm") {
|
||||
return haystack.includes("fdm");
|
||||
}
|
||||
if (category === "resin") {
|
||||
return haystack.includes("resina") || haystack.includes("resin");
|
||||
}
|
||||
if (category === "design-3d") {
|
||||
return haystack.includes("diseno") || haystack.includes("diseño") || haystack.includes("cad");
|
||||
}
|
||||
if (category === "repuestos") {
|
||||
return haystack.includes("repuesto") || haystack.includes("restauracion") || haystack.includes("pieza");
|
||||
}
|
||||
|
||||
return haystack.includes("ingenieria") || haystack.includes("industrial") || haystack.includes("tecnic");
|
||||
}
|
||||
|
||||
function orderWorks(works: PublicWork[]) {
|
||||
return [...works].sort((left, right) => {
|
||||
if (getRating(right.avg_rating) !== getRating(left.avg_rating)) {
|
||||
return getRating(right.avg_rating) - getRating(left.avg_rating);
|
||||
}
|
||||
|
||||
return right.review_count - left.review_count;
|
||||
});
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="6.6" />
|
||||
<path d="m20 20-3.6-3.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiscoverExperienceClient({
|
||||
initialWorks,
|
||||
initialQuery,
|
||||
initialCategory
|
||||
}: {
|
||||
initialMakers: PublicMaker[];
|
||||
initialWorks: PublicWork[];
|
||||
initialQuery: string;
|
||||
initialCategory: DiscoverCategory;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const works = orderWorks(initialWorks.filter((work) => matchesCategory(work, initialCategory)));
|
||||
|
||||
useEffect(() => {
|
||||
const storedPosition = window.sessionStorage.getItem(discoverReturnKey);
|
||||
if (!storedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const position = JSON.parse(storedPosition) as {
|
||||
scrollTop?: number;
|
||||
query?: string;
|
||||
category?: DiscoverCategory;
|
||||
};
|
||||
|
||||
if (position.query !== initialQuery || position.category !== initialCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
scrollRef.current?.scrollTo({ top: position.scrollTop || 0, behavior: "auto" });
|
||||
};
|
||||
|
||||
requestAnimationFrame(restore);
|
||||
window.setTimeout(restore, 120);
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(discoverReturnKey);
|
||||
}
|
||||
}, [initialCategory, initialQuery, works.length]);
|
||||
|
||||
function rememberReturnPosition(workId: string) {
|
||||
window.sessionStorage.setItem(
|
||||
discoverReturnKey,
|
||||
JSON.stringify({
|
||||
category: initialCategory,
|
||||
query: initialQuery,
|
||||
scrollTop: scrollRef.current?.scrollTop || 0,
|
||||
workId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="discover-feed-shell">
|
||||
<div className="discover-feed-topbar">
|
||||
<form action="/discover" className="discover-feed-search">
|
||||
{initialCategory !== "all" ? <input type="hidden" name="category" value={initialCategory} /> : null}
|
||||
<span aria-hidden="true">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input name="q" placeholder="Buscar trabajos, piezas, materiales..." defaultValue={initialQuery} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="discover-feed-scroll" aria-label="Feed de trabajos">
|
||||
{works.map((work, index) => {
|
||||
const images = workImages(work);
|
||||
|
||||
return (
|
||||
<article key={work.id} className="discover-feed-item">
|
||||
<section className="discover-feed-visual">
|
||||
<div className="discover-feed-gallery" aria-label={`Galeria de ${work.title}`}>
|
||||
{images.map((image, imageIndex) => (
|
||||
<div key={`${work.id}-${image}`} className="discover-feed-slide">
|
||||
<img src={image} alt={`${work.title} - imagen ${imageIndex + 1}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="discover-feed-dots" aria-hidden="true">
|
||||
{images.map((image) => (
|
||||
<span key={`${work.id}-dot-${image}`} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<span className="discover-feed-photo-count">{images.length === 1 ? "1 foto" : `${images.length} fotos | desliza`}</span>
|
||||
|
||||
<div className="discover-feed-actions" aria-label="Acciones del trabajo">
|
||||
<FavoriteToggle targetType="work" targetId={work.id} compact />
|
||||
<Link
|
||||
href={`/works/${work.slug}`}
|
||||
className="discover-feed-action-button"
|
||||
onClick={() => rememberReturnPosition(work.id)}
|
||||
aria-label="Ver trabajo completo"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="discover-feed-copy">
|
||||
<div className="discover-feed-maker-row">
|
||||
<Link href={`/makers/${work.maker_slug}`} className="discover-feed-maker">
|
||||
<span>{work.maker_name.slice(0, 2).toUpperCase()}</span>
|
||||
<strong>{work.maker_name}</strong>
|
||||
</Link>
|
||||
<div className="discover-feed-rating">
|
||||
<span className="map-home-star">*</span>
|
||||
<span>{getRating(work.avg_rating).toFixed(1)}</span>
|
||||
<span>({Math.max(work.review_count * 32, 12)})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="discover-feed-title-row">
|
||||
<h1>{work.title}</h1>
|
||||
<Link
|
||||
href={`/works/${work.slug}`}
|
||||
className="button button-primary discover-feed-cta"
|
||||
onClick={() => rememberReturnPosition(work.id)}
|
||||
>
|
||||
Ver trabajo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p>{work.summary}</p>
|
||||
|
||||
<div className="discover-feed-tags">
|
||||
{getWorkTags(work).map((tag) => (
|
||||
<span key={`${work.id}-${tag}`} className="map-home-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{works.length === 0 ? (
|
||||
<section className="discover-feed-empty">
|
||||
<span className="eyebrow">Descubrir</span>
|
||||
<h1>No encontramos trabajos</h1>
|
||||
<p>Prueba otra busqueda para volver a explorar el portfolio de makers.</p>
|
||||
<Link href="/discover" className="button button-primary">Ver todos</Link>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { serverFetch } from "../lib/api";
|
||||
import DiscoverExperienceClient from "./DiscoverExperienceClient";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
delivery_scope: string;
|
||||
availability: string;
|
||||
main_image_url: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
service_count: number;
|
||||
work_count: number;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
type PublicWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string | null;
|
||||
gallery_urls: string[] | null;
|
||||
maker_id: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type DiscoverCategory = "all" | "fdm" | "resin" | "design-3d" | "repuestos" | "ingenieria";
|
||||
|
||||
function readParam(value: string | string[] | undefined) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string): DiscoverCategory {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (normalized === "fdm" || normalized === "impresion fdm") {
|
||||
return "fdm";
|
||||
}
|
||||
if (normalized === "resina" || normalized === "impresion resina") {
|
||||
return "resin";
|
||||
}
|
||||
if (normalized === "design-3d" || normalized === "diseno 3d") {
|
||||
return "design-3d";
|
||||
}
|
||||
if (normalized === "repuestos") {
|
||||
return "repuestos";
|
||||
}
|
||||
if (normalized === "ingenieria") {
|
||||
return "ingenieria";
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
export async function DiscoverResultsScreen({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const query = readParam(searchParams?.q);
|
||||
const initialCategory = normalizeCategory(
|
||||
readParam(searchParams?.category) || readParam(searchParams?.serviceCategory)
|
||||
);
|
||||
|
||||
const requestQuery = new URLSearchParams();
|
||||
if (query) {
|
||||
requestQuery.set("q", query);
|
||||
}
|
||||
|
||||
const serialized = requestQuery.toString();
|
||||
const [makersData, worksData] = await Promise.all([
|
||||
serverFetch<{ makers: PublicMaker[] }>(`/makers${serialized ? `?${serialized}` : ""}`),
|
||||
serverFetch<{ works: PublicWork[] }>(`/works${serialized ? `?${serialized}` : ""}`)
|
||||
]);
|
||||
|
||||
return (
|
||||
<DiscoverExperienceClient
|
||||
initialMakers={makersData.makers}
|
||||
initialWorks={worksData.works}
|
||||
initialQuery={query}
|
||||
initialCategory={initialCategory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type FavoriteToggleProps = {
|
||||
targetId: string;
|
||||
targetType: "maker" | "work";
|
||||
targetSlug?: string;
|
||||
compact?: boolean;
|
||||
icon?: "heart" | "bookmark";
|
||||
};
|
||||
|
||||
const favoriteSyncEvent = "makers3d:favorite-updated";
|
||||
|
||||
function loginForCurrentPage() {
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
|
||||
}
|
||||
|
||||
function BookmarkIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M7 4.8A1.8 1.8 0 0 1 8.8 3h6.4A1.8 1.8 0 0 1 17 4.8V21l-5-3-5 3V4.8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoriteToggle({ targetId, targetType, targetSlug, compact = false, icon = "heart" }: FavoriteToggleProps) {
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState("");
|
||||
const syncKey = `${targetType}:${targetSlug || targetId}`;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadFavorite() {
|
||||
try {
|
||||
const statusQuery = targetType === "maker" && targetSlug
|
||||
? `/favorites/status?targetType=maker&targetSlug=${encodeURIComponent(targetSlug)}`
|
||||
: `/favorites/status?targetType=${targetType}&targetId=${targetId}`;
|
||||
const response = await browserFetch<{ favorited: boolean }>(
|
||||
statusQuery
|
||||
);
|
||||
if (active) {
|
||||
setFavorited(response.favorited);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setFavorited(false);
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFavorite();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [targetId, targetSlug, targetType]);
|
||||
|
||||
useEffect(() => {
|
||||
function syncFavorite(event: Event) {
|
||||
const detail = (event as CustomEvent<{ key: string; favorited: boolean }>).detail;
|
||||
if (detail?.key === syncKey) {
|
||||
setFavorited(detail.favorited);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(favoriteSyncEvent, syncFavorite);
|
||||
return () => window.removeEventListener(favoriteSyncEvent, syncFavorite);
|
||||
}, [syncKey]);
|
||||
|
||||
async function toggleFavorite() {
|
||||
setStatus("");
|
||||
try {
|
||||
const identifier = targetSlug || targetId || "current";
|
||||
const endpoint = targetType === "maker"
|
||||
? `/favorites/makers/${encodeURIComponent(identifier)}`
|
||||
: `/favorites/works/${encodeURIComponent(identifier)}`;
|
||||
const response = await browserFetch<{ favorited: boolean }>(endpoint, {
|
||||
method: favorited ? "DELETE" : "POST"
|
||||
});
|
||||
setFavorited(response.favorited);
|
||||
window.dispatchEvent(new CustomEvent(favoriteSyncEvent, {
|
||||
detail: {
|
||||
key: syncKey,
|
||||
favorited: response.favorited
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
const message = apiError.message || "";
|
||||
if (apiError.status === 401 || apiError.status === 403 || message.includes("Authentication")) {
|
||||
loginForCurrentPage();
|
||||
return;
|
||||
}
|
||||
setStatus(apiError.status ? `No se pudo guardar (${apiError.status}).` : "No se pudo actualizar favoritos.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`favorite-toggle-wrap ${compact ? "is-compact" : ""} favorite-icon-${icon}`}>
|
||||
<button
|
||||
className={`favorite-toggle ${favorited ? "is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={toggleFavorite}
|
||||
disabled={loading}
|
||||
aria-pressed={favorited}
|
||||
>
|
||||
<span className="favorite-heart" aria-hidden="true">
|
||||
{icon === "bookmark" ? <BookmarkIcon active={favorited} /> : (favorited ? "\u2764\uFE0F" : "\u2661")}
|
||||
</span>
|
||||
<span>{favorited ? "Guardado" : "Guardar"}</span>
|
||||
</button>
|
||||
{status ? <span className="mini-note">{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type FavoriteMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
main_image_url: string | null;
|
||||
availability: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
};
|
||||
|
||||
type FavoriteWork = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
technology: string;
|
||||
material: string;
|
||||
image_url: string;
|
||||
maker_slug: string;
|
||||
maker_name: string;
|
||||
};
|
||||
|
||||
type FavoritesResponse = {
|
||||
makers: FavoriteMaker[];
|
||||
works: FavoriteWork[];
|
||||
};
|
||||
|
||||
function rating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed.toFixed(1) : "0.0";
|
||||
}
|
||||
|
||||
export default function FavoritesClient() {
|
||||
const [activeTab, setActiveTab] = useState<"makers" | "works">("makers");
|
||||
const [favorites, setFavorites] = useState<FavoritesResponse>({ makers: [], works: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
async function loadFavorites() {
|
||||
setStatus("");
|
||||
try {
|
||||
const response = await browserFetch<FavoritesResponse>("/favorites");
|
||||
setFavorites(response);
|
||||
setNeedsLogin(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message.includes("Authentication")) {
|
||||
setNeedsLogin(true);
|
||||
return;
|
||||
}
|
||||
setStatus("No se pudieron cargar tus favoritos.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFavorites();
|
||||
}, []);
|
||||
|
||||
async function removeFavorite(type: "maker" | "work", id: string) {
|
||||
const endpoint = type === "maker" ? `/favorites/makers/${id}` : `/favorites/works/${id}`;
|
||||
await browserFetch(endpoint, { method: "DELETE" });
|
||||
await loadFavorites();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="profile-account-card stack">
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Cargando guardados</h1>
|
||||
<p className="muted">Estamos preparando tus makers y trabajos favoritos.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsLogin) {
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="profile-account-card stack">
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Inicia sesion para guardar favoritos</h1>
|
||||
<p className="muted">Tus makers y trabajos guardados quedan asociados a tu cuenta para recuperarlos desde cualquier pestana.</p>
|
||||
<Link href="/login?returnTo=%2Ffavorites" className="button button-primary">Entrar</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleMakers = activeTab === "makers";
|
||||
const empty = visibleMakers ? favorites.makers.length === 0 : favorites.works.length === 0;
|
||||
|
||||
return (
|
||||
<section className="favorites-shell">
|
||||
<div className="favorites-header">
|
||||
<div>
|
||||
<span className="eyebrow">Favoritos</span>
|
||||
<h1 className="section-title">Guardados para volver rapido</h1>
|
||||
<p className="muted">Separados por makers y trabajos para que puedas comparar proveedores o ejemplos concretos.</p>
|
||||
</div>
|
||||
<div className="favorites-counts">
|
||||
<span>{favorites.makers.length} makers</span>
|
||||
<span>{favorites.works.length} trabajos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="favorites-tabs" role="tablist" aria-label="Secciones de favoritos">
|
||||
<button className={activeTab === "makers" ? "is-active" : ""} type="button" onClick={() => setActiveTab("makers")}>
|
||||
Makers
|
||||
</button>
|
||||
<button className={activeTab === "works" ? "is-active" : ""} type="button" onClick={() => setActiveTab("works")}>
|
||||
Trabajos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status ? <p className="messages-status">{status}</p> : null}
|
||||
|
||||
{empty ? (
|
||||
<div className="profile-account-card stack">
|
||||
<h2 className="section-title">{visibleMakers ? "Todavia no guardaste makers" : "Todavia no guardaste trabajos"}</h2>
|
||||
<p className="muted">Usa el boton Guardar desde un perfil de maker o desde el detalle de un trabajo.</p>
|
||||
<Link href="/discover" className="button button-primary">Descubrir makers</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleMakers ? (
|
||||
<div className="favorites-grid">
|
||||
{favorites.makers.map((maker) => (
|
||||
<article key={maker.id} className="favorite-card">
|
||||
<img className="thumb" src={maker.main_image_url || "/demo/maker-hero-1.svg"} alt={maker.business_name} />
|
||||
<div className="favorite-card-body">
|
||||
<div>
|
||||
<strong>{maker.business_name}</strong>
|
||||
<span className="mini-note">{maker.city || "Cordoba"}, {maker.province || "Argentina"}</span>
|
||||
</div>
|
||||
<div className="badges">
|
||||
<span className="badge">Rating {rating(maker.avg_rating)}</span>
|
||||
<span className="badge">{maker.review_count} resenas</span>
|
||||
<span className="badge">{maker.availability || "available"}</span>
|
||||
</div>
|
||||
<div className="favorite-card-actions">
|
||||
<Link href={`/makers/${maker.slug}`} className="button button-primary">Ver perfil</Link>
|
||||
<button className="button button-secondary" type="button" onClick={() => removeFavorite("maker", maker.id)}>Quitar</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="favorites-grid">
|
||||
{favorites.works.map((work) => (
|
||||
<article key={work.id} className="favorite-card">
|
||||
<img className="thumb" src={work.image_url || "/demo/work-custom.svg"} alt={work.title} />
|
||||
<div className="favorite-card-body">
|
||||
<div>
|
||||
<strong>{work.title}</strong>
|
||||
<span className="mini-note">{work.maker_name}</span>
|
||||
</div>
|
||||
<p className="muted">{work.summary}</p>
|
||||
<div className="badges">
|
||||
<span className="badge">{work.technology}</span>
|
||||
<span className="badge">{work.material}</span>
|
||||
</div>
|
||||
<div className="favorite-card-actions">
|
||||
<Link href={`/works/${work.slug}`} className="button button-primary">Ver trabajo</Link>
|
||||
<button className="button button-secondary" type="button" onClick={() => removeFavorite("work", work.id)}>Quitar</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
makerId: string;
|
||||
sourceType: "profile" | "service" | "work";
|
||||
sourceId?: string | null;
|
||||
};
|
||||
|
||||
export function InquiryForm({ makerId, sourceType, sourceId }: Props) {
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [form, setForm] = useState({
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
hasModel: false,
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping"
|
||||
});
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setStatus("Enviando consulta...");
|
||||
try {
|
||||
await browserFetch("/inquiries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
makerId,
|
||||
sourceType,
|
||||
sourceId,
|
||||
...form
|
||||
})
|
||||
});
|
||||
setStatus("Consulta enviada. Ya puedes seguirla desde tu cuenta.");
|
||||
setForm({
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
hasModel: false,
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping"
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="inquiry-card" onSubmit={onSubmit}>
|
||||
<div className="guide-steps">
|
||||
<div className="guide-step">
|
||||
<div className="step-index">1</div>
|
||||
<div>
|
||||
<strong>Que quieres hacer?</strong>
|
||||
<div className="mini-note">Cuanto mejor expliques el uso, mejor se ajusta la respuesta del maker.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inquiry-section">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Quiero algo muy parecido, necesito modificar una pieza o tengo una idea similar..."
|
||||
value={form.needText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, needText: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inquiry-section">
|
||||
<strong>Detalles que ayudan al maker</strong>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Tamano o medidas aproximadas"
|
||||
value={form.sizeText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, sizeText: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Material preferido"
|
||||
value={form.materialPreference}
|
||||
onChange={(event) => setForm((current) => ({ ...current, materialPreference: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.quantity}
|
||||
onChange={(event) => setForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={form.urgency}
|
||||
onChange={(event) => setForm((current) => ({ ...current, urgency: event.target.value }))}
|
||||
>
|
||||
<option value="low">Sin urgencia</option>
|
||||
<option value="medium">Esta semana</option>
|
||||
<option value="high">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-grid-compact">
|
||||
<label className="choice-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.hasModel}
|
||||
onChange={(event) => setForm((current) => ({ ...current, hasModel: event.target.checked }))}
|
||||
/>
|
||||
<span>Ya tengo archivo o pieza de referencia</span>
|
||||
</label>
|
||||
<select
|
||||
className="select"
|
||||
value={form.deliveryType}
|
||||
onChange={(event) => setForm((current) => ({ ...current, deliveryType: event.target.value }))}
|
||||
>
|
||||
<option value="shipping">Necesito envio</option>
|
||||
<option value="local">Puedo retirar personalmente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="button button-primary" type="submit">Enviar consulta</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
makerId: string;
|
||||
sourceType: "profile" | "service" | "work";
|
||||
sourceId?: string;
|
||||
makerName: string;
|
||||
contextTitle: string;
|
||||
returnTo: string;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
needText: string;
|
||||
sizeText: string;
|
||||
usageText: string;
|
||||
materialPreference: string;
|
||||
quantity: number;
|
||||
urgency: string;
|
||||
deliveryType: string;
|
||||
hasModel: boolean;
|
||||
referenceNotes: string;
|
||||
};
|
||||
|
||||
const initialDraft: Draft = {
|
||||
needText: "",
|
||||
sizeText: "",
|
||||
usageText: "",
|
||||
materialPreference: "",
|
||||
quantity: 1,
|
||||
urgency: "medium",
|
||||
deliveryType: "shipping",
|
||||
hasModel: false,
|
||||
referenceNotes: ""
|
||||
};
|
||||
|
||||
export default function InquiryWizardClient({ makerId, sourceType, sourceId, makerName, contextTitle, returnTo }: Props) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [status, setStatus] = useState("");
|
||||
const [me, setMe] = useState<{ id: string } | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
|
||||
const storageKey = useMemo(
|
||||
() => `makers3d_inquiry_draft:${makerId}:${sourceType}:${sourceId || "root"}`,
|
||||
[makerId, sourceId, sourceType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.sessionStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
setDraft({ ...initialDraft, ...JSON.parse(saved) as Draft });
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
void browserFetch<{ user: { id: string } | null }>("/auth/me")
|
||||
.then((result) => setMe(result.user))
|
||||
.catch(() => setMe(null));
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(draft));
|
||||
}, [draft, storageKey]);
|
||||
|
||||
function nextStep() {
|
||||
setStep((current) => Math.min(5, current + 1));
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
setStep((current) => Math.max(1, current - 1));
|
||||
}
|
||||
|
||||
async function submitInquiry() {
|
||||
if (!me) {
|
||||
const nextUrl = `/consultas/nueva?makerId=${encodeURIComponent(makerId)}&sourceType=${encodeURIComponent(sourceType)}&sourceId=${encodeURIComponent(sourceId || "")}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(contextTitle)}`;
|
||||
window.location.href = `/register?returnTo=${encodeURIComponent(nextUrl)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Enviando consulta...");
|
||||
try {
|
||||
await browserFetch("/inquiries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
makerId,
|
||||
sourceType,
|
||||
sourceId,
|
||||
needText: `${draft.needText}\n\nUso final: ${draft.usageText}\nReferencia: ${draft.referenceNotes}`.trim(),
|
||||
sizeText: draft.sizeText,
|
||||
hasModel: draft.hasModel,
|
||||
materialPreference: draft.materialPreference,
|
||||
quantity: draft.quantity,
|
||||
urgency: draft.urgency,
|
||||
deliveryType: draft.deliveryType
|
||||
})
|
||||
});
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
setStatus("Consulta enviada.");
|
||||
router.push("/account/inbox");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar la consulta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<div className="panel wizard-context stack">
|
||||
<span className="eyebrow">Consulta guiada</span>
|
||||
<h1 className="section-title">Paso {step} de 5</h1>
|
||||
<div className="list-item stack">
|
||||
<strong>{contextTitle}</strong>
|
||||
<span className="mini-note">Maker: {makerName}</span>
|
||||
<span className="mini-note">Si vuelves tras registrarte, el borrador se conserva.</span>
|
||||
</div>
|
||||
<div className="wizard-progress">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<span key={value} className={`wizard-dot ${value <= step ? "active" : ""}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel stack">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<span className="eyebrow">Necesidad</span>
|
||||
<h2 className="section-title">Que necesitas resolver?</h2>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Quiero algo igual, modificar una pieza o fabricar algo parecido..."
|
||||
value={draft.needText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, needText: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<span className="eyebrow">Detalles del trabajo</span>
|
||||
<h2 className="section-title">Dimension, material y referencia</h2>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Tamano o medidas"
|
||||
value={draft.sizeText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, sizeText: event.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Material preferido"
|
||||
value={draft.materialPreference}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, materialPreference: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="choice-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.hasModel}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, hasModel: event.target.checked }))}
|
||||
/>
|
||||
<span>Ya tengo archivo o una pieza de referencia</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Describe el archivo, la pieza o lo que puedes adjuntar despues en la conversacion."
|
||||
value={draft.referenceNotes}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, referenceNotes: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<span className="eyebrow">Uso final</span>
|
||||
<h2 className="section-title">Para que se utilizara?</h2>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Pieza funcional, exterior, calor, agua, decoracion, prototipo..."
|
||||
value={draft.usageText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, usageText: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<>
|
||||
<span className="eyebrow">Urgencia y entrega</span>
|
||||
<h2 className="section-title">Cantidad, plazo y modalidad</h2>
|
||||
<div className="input-grid-compact">
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.quantity}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, quantity: Number(event.target.value) }))}
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.urgency}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, urgency: event.target.value }))}
|
||||
>
|
||||
<option value="low">Sin urgencia</option>
|
||||
<option value="medium">Esta semana</option>
|
||||
<option value="high">Urgente</option>
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.deliveryType}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, deliveryType: event.target.value }))}
|
||||
>
|
||||
<option value="shipping">Necesito envio</option>
|
||||
<option value="local">Puedo retirar</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<>
|
||||
<span className="eyebrow">Resumen</span>
|
||||
<h2 className="section-title">Revisa antes de enviar</h2>
|
||||
<div className="checklist-list">
|
||||
<div className="checklist-item"><strong>Necesidad</strong><span>{draft.needText || "Pendiente"}</span></div>
|
||||
<div className="checklist-item"><strong>Detalles</strong><span>{draft.sizeText || "Sin medidas"} / {draft.materialPreference || "Sin material"}</span></div>
|
||||
<div className="checklist-item"><strong>Uso final</strong><span>{draft.usageText || "No indicado"}</span></div>
|
||||
<div className="checklist-item"><strong>Entrega</strong><span>{draft.quantity} unidad / {draft.deliveryType}</span></div>
|
||||
</div>
|
||||
{!me ? (
|
||||
<p className="muted">Para enviar la consulta necesitas crear cuenta o entrar. El borrador no se pierde.</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="wizard-actions">
|
||||
<a href={returnTo} className="button button-ghost">Volver</a>
|
||||
<div className="wizard-actions-right">
|
||||
{step > 1 ? <button className="button button-secondary" type="button" onClick={previousStep}>Anterior</button> : null}
|
||||
{step < 5 ? (
|
||||
<button className="button button-primary" type="button" onClick={nextStep}>Continuar</button>
|
||||
) : (
|
||||
<button className="button button-primary" type="button" onClick={submitInquiry}>Enviar consulta</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
|
||||
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
|
||||
type DiscoverMapMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type DisplayMarker = DiscoverMapMarker & {
|
||||
displayLatitude: number;
|
||||
displayLongitude: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
type ClusterGroup = {
|
||||
key: string;
|
||||
count: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
markers: DisplayMarker[];
|
||||
};
|
||||
|
||||
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
|
||||
const userApproximationRadius = 900;
|
||||
|
||||
function hashValue(input: string, seed: number) {
|
||||
let value = seed;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toOffset(value: number, spread: number) {
|
||||
return ((value % 1000) / 999 - 0.5) * spread;
|
||||
}
|
||||
|
||||
function toDisplayMarkers(markers: DiscoverMapMarker[]): DisplayMarker[] {
|
||||
return markers.map((marker) => {
|
||||
const latSeed = hashValue(marker.id, 17);
|
||||
const lngSeed = hashValue(marker.slug, 29);
|
||||
const radiusSeed = hashValue(marker.businessName, 43);
|
||||
|
||||
return {
|
||||
...marker,
|
||||
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
|
||||
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
|
||||
radius: 8 + (radiusSeed % 4)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getClusterRadius(zoom: number) {
|
||||
if (zoom >= 15) {
|
||||
return 0;
|
||||
}
|
||||
if (zoom >= 14) {
|
||||
return 34;
|
||||
}
|
||||
if (zoom >= 13) {
|
||||
return 42;
|
||||
}
|
||||
if (zoom >= 12) {
|
||||
return 50;
|
||||
}
|
||||
if (zoom >= 11) {
|
||||
return 58;
|
||||
}
|
||||
return 66;
|
||||
}
|
||||
|
||||
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
|
||||
const clusterRadius = getClusterRadius(zoom);
|
||||
|
||||
if (clusterRadius === 0) {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.id,
|
||||
count: 1,
|
||||
latitude: marker.displayLatitude,
|
||||
longitude: marker.displayLongitude,
|
||||
markers: [marker]
|
||||
}));
|
||||
}
|
||||
|
||||
const projectedMarkers = markers.map((marker) => ({
|
||||
marker,
|
||||
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
|
||||
}));
|
||||
const visited = new Set<number>();
|
||||
const groups: ClusterGroup[] = [];
|
||||
|
||||
for (let index = 0; index < projectedMarkers.length; index += 1) {
|
||||
if (visited.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const memberIndexes: number[] = [];
|
||||
visited.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift() as number;
|
||||
const currentMarker = projectedMarkers[currentIndex];
|
||||
memberIndexes.push(currentIndex);
|
||||
|
||||
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
|
||||
if (visited.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateMarker = projectedMarkers[candidateIndex];
|
||||
const deltaX = currentMarker.point.x - candidateMarker.point.x;
|
||||
const deltaY = currentMarker.point.y - candidateMarker.point.y;
|
||||
|
||||
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
|
||||
visited.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
|
||||
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
|
||||
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
|
||||
|
||||
groups.push({
|
||||
key: bucket.map((marker) => marker.id).sort().join(":"),
|
||||
count: bucket.length,
|
||||
latitude,
|
||||
longitude,
|
||||
markers: bucket
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildClusterIcon(count: number): DivIcon {
|
||||
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
|
||||
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
|
||||
|
||||
return divIcon({
|
||||
className: "map-home-cluster-icon-shell",
|
||||
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
}
|
||||
|
||||
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12);
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14
|
||||
}
|
||||
);
|
||||
}, [map, markers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map, mapRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClusteredMarkerLayer({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker
|
||||
}: {
|
||||
markers: DisplayMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
||||
useMapEvents({
|
||||
zoomend() {
|
||||
setZoom(map.getZoom());
|
||||
}
|
||||
});
|
||||
|
||||
const groups = buildClusterGroups(markers, map, zoom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
if (group.count === 1) {
|
||||
const marker = group.markers[0];
|
||||
const isSelected = marker.id === selectedMarkerId;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={marker.id}
|
||||
center={[marker.displayLatitude, marker.displayLongitude]}
|
||||
radius={isSelected ? marker.radius + 4 : marker.radius}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: isSelected ? 3 : 2,
|
||||
fillColor: isSelected ? "#4f89ff" : "#10358d",
|
||||
fillOpacity: 0.98
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onSelectMarker(marker.id)
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{marker.businessName}</strong>
|
||||
<span>{marker.city}, {marker.province}</span>
|
||||
<a href={`/makers/${marker.slug}`}>Ver maker</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={group.key}
|
||||
position={[group.latitude, group.longitude]}
|
||||
icon={buildClusterIcon(group.count)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
map.fitBounds(
|
||||
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [48, 48],
|
||||
maxZoom: 15,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{group.count} makers en esta zona</strong>
|
||||
<span>Toca el grupo para acercar y separarlos.</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InteractiveDiscoverMap({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker,
|
||||
onApplyVisibleArea,
|
||||
focusRequest
|
||||
}: {
|
||||
markers: DiscoverMapMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
onApplyVisibleArea: (makerIds: string[]) => void;
|
||||
focusRequest: number;
|
||||
}) {
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const displayMarkers = toDisplayMarkers(markers);
|
||||
|
||||
const focusMarkers = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12, { animate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const applyVisibleArea = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
onApplyVisibleArea(markers.map((marker) => marker.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const visibleIds = displayMarkers
|
||||
.filter((marker) => bounds.contains([marker.displayLatitude, marker.displayLongitude]))
|
||||
.map((marker) => marker.id);
|
||||
|
||||
onApplyVisibleArea(visibleIds);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequest > 0) {
|
||||
focusMarkers();
|
||||
}
|
||||
}, [focusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !selectedMarkerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMarker = displayMarkers.find((marker) => marker.id === selectedMarkerId);
|
||||
if (!selectedMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.flyTo([selectedMarker.displayLatitude, selectedMarker.displayLongitude], Math.max(map.getZoom(), 13), {
|
||||
animate: true,
|
||||
duration: 0.6
|
||||
});
|
||||
}, [selectedMarkerId, displayMarkers]);
|
||||
|
||||
return (
|
||||
<section className="discover-map-stage">
|
||||
<div className="discover-map-surface">
|
||||
<MapContainer center={cordobaCenter} zoom={13} scrollWheelZoom className="discover-leaflet">
|
||||
<RegisterMapInstance mapRef={mapRef} />
|
||||
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap contributors © CARTO"
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
<FitMapToMarkers markers={displayMarkers} />
|
||||
|
||||
<Circle
|
||||
center={cordobaCenter}
|
||||
radius={userApproximationRadius}
|
||||
pathOptions={{
|
||||
color: "#2d7cff",
|
||||
weight: 2,
|
||||
fillColor: "#2d7cff",
|
||||
fillOpacity: 0.14
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={cordobaCenter}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
<ClusteredMarkerLayer
|
||||
markers={displayMarkers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
onSelectMarker={onSelectMarker}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="discover-map-overlay" aria-hidden="false">
|
||||
<div className="discover-map-side-tools">
|
||||
<button className="discover-map-control" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5.4" />
|
||||
<path d="M12 2v3" />
|
||||
<path d="M12 19v3" />
|
||||
<path d="M2 12h3" />
|
||||
<path d="M19 12h3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="discover-map-control" type="button" aria-label="Capas del mapa">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 4 8 4-8 4-8-4 8-4Z" />
|
||||
<path d="m4 12 8 4 8-4" />
|
||||
<path d="m4 16 8 4 8-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="discover-map-radar" type="button" aria-label="Radar maker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="2.2" />
|
||||
<path d="M12 5a7 7 0 0 1 7 7" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" />
|
||||
<path d="M5 12a7 7 0 0 1 7-7" />
|
||||
</svg>
|
||||
<span>Radar Maker</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="discover-map-cta" type="button" onClick={applyVisibleArea}>
|
||||
Buscar en esta zona
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export type DiscoverMapMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const InteractiveDiscoverMap = dynamic(
|
||||
() => import("./InteractiveDiscoverMap"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<section className="discover-map-stage">
|
||||
<div className="discover-map-loading">Cargando mapa real...</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
export default function InteractiveDiscoverMapClient({
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
onSelectMarker,
|
||||
onApplyVisibleArea,
|
||||
focusRequest
|
||||
}: {
|
||||
markers: DiscoverMapMarker[];
|
||||
selectedMarkerId?: string;
|
||||
onSelectMarker: (makerId: string) => void;
|
||||
onApplyVisibleArea: (makerIds: string[]) => void;
|
||||
focusRequest: number;
|
||||
}) {
|
||||
return (
|
||||
<InteractiveDiscoverMap
|
||||
markers={markers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
onSelectMarker={onSelectMarker}
|
||||
onApplyVisibleArea={onApplyVisibleArea}
|
||||
focusRequest={focusRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet";
|
||||
import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
|
||||
type MakerMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type DisplayMarker = MakerMarker & {
|
||||
displayLatitude: number;
|
||||
displayLongitude: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
type ClusterGroup = {
|
||||
key: string;
|
||||
count: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
markers: DisplayMarker[];
|
||||
};
|
||||
|
||||
const cordobaCenter: [number, number] = [-31.4201, -64.1888];
|
||||
const userApproximationRadius = 900;
|
||||
|
||||
function hashValue(input: string, seed: number) {
|
||||
let value = seed;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
value = (value * 33 + input.charCodeAt(index) + index) % 1000003;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toOffset(value: number, spread: number) {
|
||||
return ((value % 1000) / 999 - 0.5) * spread;
|
||||
}
|
||||
|
||||
function toDisplayMarkers(markers: MakerMarker[]): DisplayMarker[] {
|
||||
return markers.map((marker) => {
|
||||
const latSeed = hashValue(marker.id, 17);
|
||||
const lngSeed = hashValue(marker.slug, 29);
|
||||
const radiusSeed = hashValue(marker.businessName, 43);
|
||||
|
||||
return {
|
||||
...marker,
|
||||
displayLatitude: marker.latitude + toOffset(latSeed, 0.0072),
|
||||
displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094),
|
||||
radius: 8 + (radiusSeed % 4)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getClusterRadius(zoom: number) {
|
||||
if (zoom >= 15) {
|
||||
return 0;
|
||||
}
|
||||
if (zoom >= 14) {
|
||||
return 34;
|
||||
}
|
||||
if (zoom >= 13) {
|
||||
return 42;
|
||||
}
|
||||
if (zoom >= 12) {
|
||||
return 50;
|
||||
}
|
||||
if (zoom >= 11) {
|
||||
return 58;
|
||||
}
|
||||
return 66;
|
||||
}
|
||||
|
||||
function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) {
|
||||
const clusterRadius = getClusterRadius(zoom);
|
||||
|
||||
if (clusterRadius === 0) {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.id,
|
||||
count: 1,
|
||||
latitude: marker.displayLatitude,
|
||||
longitude: marker.displayLongitude,
|
||||
markers: [marker]
|
||||
}));
|
||||
}
|
||||
|
||||
const projectedMarkers = markers.map((marker) => ({
|
||||
marker,
|
||||
point: map.project([marker.displayLatitude, marker.displayLongitude], zoom)
|
||||
}));
|
||||
const visited = new Set<number>();
|
||||
const groups: ClusterGroup[] = [];
|
||||
|
||||
for (let index = 0; index < projectedMarkers.length; index += 1) {
|
||||
if (visited.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const memberIndexes: number[] = [];
|
||||
visited.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift() as number;
|
||||
const currentMarker = projectedMarkers[currentIndex];
|
||||
memberIndexes.push(currentIndex);
|
||||
|
||||
for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) {
|
||||
if (visited.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateMarker = projectedMarkers[candidateIndex];
|
||||
const deltaX = currentMarker.point.x - candidateMarker.point.x;
|
||||
const deltaY = currentMarker.point.y - candidateMarker.point.y;
|
||||
|
||||
if (Math.hypot(deltaX, deltaY) <= clusterRadius) {
|
||||
visited.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker);
|
||||
const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length;
|
||||
const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length;
|
||||
|
||||
groups.push({
|
||||
key: bucket.map((marker) => marker.id).sort().join(":"),
|
||||
count: bucket.length,
|
||||
latitude,
|
||||
longitude,
|
||||
markers: bucket
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildClusterIcon(count: number): DivIcon {
|
||||
const size = count >= 10 ? 60 : count >= 5 ? 54 : 48;
|
||||
const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small";
|
||||
|
||||
return divIcon({
|
||||
className: "map-home-cluster-icon-shell",
|
||||
html: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
}
|
||||
|
||||
function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]);
|
||||
map.fitBounds(bounds, {
|
||||
padding: [40, 40],
|
||||
maxZoom: 14
|
||||
});
|
||||
}, [map, markers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClusteredMarkerLayer({ markers }: { markers: DisplayMarker[] }) {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
||||
useMapEvents({
|
||||
zoomend() {
|
||||
setZoom(map.getZoom());
|
||||
}
|
||||
});
|
||||
|
||||
const groups = buildClusterGroups(markers, map, zoom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
if (group.count === 1) {
|
||||
const marker = group.markers[0];
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={marker.id}
|
||||
center={[marker.displayLatitude, marker.displayLongitude]}
|
||||
radius={marker.radius}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#10358d",
|
||||
fillOpacity: 0.95
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{marker.businessName}</strong>
|
||||
<span>{marker.city}, {marker.province}</span>
|
||||
<a href={`/makers/${marker.slug}`}>Ver maker</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={group.key}
|
||||
position={[group.latitude, group.longitude]}
|
||||
icon={buildClusterIcon(group.count)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
map.fitBounds(
|
||||
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [48, 48],
|
||||
maxZoom: 15,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
||||
<div className="map-home-popup-card">
|
||||
<strong>{group.count} makers en esta zona</strong>
|
||||
<span>Toca el grupo para acercar y separarlos.</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map, mapRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) {
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const displayMarkers = toDisplayMarkers(markers);
|
||||
|
||||
const focusMarkers = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers.length === 0) {
|
||||
map.setView(cordobaCenter, 12, { animate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
||||
{
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="map-home-map">
|
||||
<div className="map-home-realmap">
|
||||
<MapContainer
|
||||
center={cordobaCenter}
|
||||
zoom={13}
|
||||
scrollWheelZoom
|
||||
className="map-home-leaflet"
|
||||
>
|
||||
<RegisterMapInstance mapRef={mapRef} />
|
||||
|
||||
<TileLayer
|
||||
attribution='© OpenStreetMap contributors © CARTO'
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
<FitMapToMarkers markers={displayMarkers} />
|
||||
|
||||
<Circle
|
||||
center={cordobaCenter}
|
||||
radius={userApproximationRadius}
|
||||
pathOptions={{
|
||||
color: "#1d67ff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 0.1
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={cordobaCenter}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: "#2e7cff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
<ClusteredMarkerLayer markers={displayMarkers} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="map-home-map-overlay" aria-hidden="false">
|
||||
<button className="map-home-crosshair" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5.4" />
|
||||
<path d="M12 2v3" />
|
||||
<path d="M12 19v3" />
|
||||
<path d="M2 12h3" />
|
||||
<path d="M19 12h3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="map-home-area-cta" type="button" onClick={focusMarkers}>
|
||||
Buscar en esta area
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
type MakerMarker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
businessName: string;
|
||||
city: string;
|
||||
province: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const InteractiveMakerMap = dynamic(() => import("./InteractiveMakerMap"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<section className="map-home-map">
|
||||
<div className="map-home-map-loading">Cargando mapa real...</div>
|
||||
</section>
|
||||
)
|
||||
});
|
||||
|
||||
export default function InteractiveMakerMapClient({ markers }: { markers: MakerMarker[] }) {
|
||||
return <InteractiveMakerMap markers={markers} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { notifyAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function LoginForm({ redirectTo = "/account" }: { redirectTo?: string }) {
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ email: "", password: "" });
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const response = await browserFetch<{ user: { role: string } }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
notifyAuthChanged("login");
|
||||
window.location.href = response.user.role === "admin" ? "/app/backoffice" : redirectTo;
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "No se pudo entrar");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={onSubmit}>
|
||||
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
|
||||
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
|
||||
<button className="button button-primary" type="submit">Entrar</button>
|
||||
{error ? <p className="muted">{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
export type MapHomeMakerListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
city: string;
|
||||
province: string;
|
||||
imageUrl: string;
|
||||
distance: string;
|
||||
rating: string;
|
||||
reviewCount: number;
|
||||
satisfaction: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type FavoritesResponse = {
|
||||
makers: Array<{ id: string; slug: string }>;
|
||||
};
|
||||
|
||||
function loginForCurrentPage() {
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?returnTo=${encodeURIComponent(currentPath)}`);
|
||||
}
|
||||
|
||||
export function MapHomeMakerList({ makers }: { makers: MapHomeMakerListItem[] }) {
|
||||
const [favoriteSlugs, setFavoriteSlugs] = useState<Set<string>>(new Set());
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [loadingSlug, setLoadingSlug] = useState("");
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
const response = await browserFetch<FavoritesResponse>("/favorites");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setFavoriteSlugs(new Set(response.makers.map((maker) => maker.slug)));
|
||||
setFavoriteIds(new Set(response.makers.map((maker) => maker.id)));
|
||||
setNeedsLogin(false);
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
if (active && (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication"))) {
|
||||
setNeedsLogin(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFavorites();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function toggleFavorite(maker: MapHomeMakerListItem) {
|
||||
if (needsLogin) {
|
||||
loginForCurrentPage();
|
||||
return;
|
||||
}
|
||||
|
||||
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
|
||||
setLoadingSlug(maker.slug);
|
||||
|
||||
try {
|
||||
await browserFetch<{ favorited: boolean }>(`/favorites/makers/${encodeURIComponent(maker.slug)}`, {
|
||||
method: isFavorite ? "DELETE" : "POST"
|
||||
});
|
||||
|
||||
setFavoriteSlugs((current) => {
|
||||
const next = new Set(current);
|
||||
if (isFavorite) {
|
||||
next.delete(maker.slug);
|
||||
} else {
|
||||
next.add(maker.slug);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFavoriteIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (isFavorite) {
|
||||
next.delete(maker.id);
|
||||
} else {
|
||||
next.add(maker.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
const apiError = error as Error & { status?: number };
|
||||
if (apiError.status === 401 || apiError.status === 403 || apiError.message.includes("Authentication")) {
|
||||
loginForCurrentPage();
|
||||
}
|
||||
} finally {
|
||||
setLoadingSlug("");
|
||||
}
|
||||
}
|
||||
|
||||
if (makers.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
No encontramos makers con esa busqueda. Prueba otra categoria o abre la vista completa de descubrir.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="map-home-maker-list">
|
||||
{makers.map((maker) => {
|
||||
const isFavorite = favoriteSlugs.has(maker.slug) || favoriteIds.has(maker.id);
|
||||
return (
|
||||
<article key={maker.id} className="map-home-maker-card">
|
||||
<Link
|
||||
href={`/makers/${maker.slug}`}
|
||||
className="map-home-maker-card-main map-home-maker-card-link"
|
||||
aria-label={`Abrir perfil de ${maker.name}`}
|
||||
>
|
||||
<div className="map-home-maker-media">
|
||||
<img className="thumb" src={maker.imageUrl} alt={maker.name} />
|
||||
<span className="map-home-distance-pill">{maker.distance}</span>
|
||||
</div>
|
||||
<div className="map-home-maker-body">
|
||||
<div className="map-home-maker-topline">
|
||||
<div className="map-home-maker-brand">
|
||||
<span className="map-home-maker-badge">{maker.name.slice(0, 2).toUpperCase()}</span>
|
||||
<div className="map-home-maker-copy">
|
||||
<div className="map-home-maker-heading">
|
||||
<strong>{maker.name}</strong>
|
||||
<span className="map-home-online-dot" />
|
||||
</div>
|
||||
<span className="map-home-maker-subline">{maker.city}, {maker.province}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-home-maker-score">
|
||||
<span className="map-home-star">*</span>
|
||||
<span>{maker.rating} ({maker.reviewCount})</span>
|
||||
<span>|</span>
|
||||
<span>{maker.satisfaction}</span>
|
||||
</div>
|
||||
|
||||
<div className="map-home-tag-row">
|
||||
{maker.tags.map((tag) => (
|
||||
<span key={`${maker.id}-${tag}`} className="map-home-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
className={`map-home-bookmark ${isFavorite ? "is-active" : ""}`}
|
||||
type="button"
|
||||
aria-label={isFavorite ? `Quitar ${maker.name} de favoritos` : `Guardar ${maker.name} en favoritos`}
|
||||
aria-pressed={isFavorite}
|
||||
disabled={loadingSlug === maker.slug}
|
||||
onClick={() => void toggleFavorite(maker)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.2L6 20V5.5a1 1 0 0 1 1-1Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import InteractiveMakerMapClient from "./InteractiveMakerMapClient";
|
||||
import { MapHomeMakerList, type MapHomeMakerListItem } from "./MapHomeMakerList";
|
||||
import { serverFetch } from "../lib/api";
|
||||
|
||||
type PublicMaker = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
main_image_url: string | null;
|
||||
avg_rating: number | string;
|
||||
review_count: number;
|
||||
service_count: number;
|
||||
work_count: number;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
public_locations?: Array<{
|
||||
id: string;
|
||||
label?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}>;
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
type HomeCategory = "all" | "print-3d" | "design-3d" | "resin" | "fdm";
|
||||
|
||||
const quickCategories: Array<{ id: HomeCategory; label: string }> = [
|
||||
{ id: "print-3d", label: "Impresion 3D" },
|
||||
{ id: "design-3d", label: "Diseno 3D" },
|
||||
{ id: "resin", label: "Resina" },
|
||||
{ id: "fdm", label: "FDM" }
|
||||
];
|
||||
|
||||
const preferredOrder = [
|
||||
"MakerLab 3D",
|
||||
"PrintCraft",
|
||||
"3D Ideas",
|
||||
"ImpresionAR",
|
||||
"ProtoWorks",
|
||||
"Resin Forge"
|
||||
];
|
||||
|
||||
const distanceLabels = ["1,2 km", "1,8 km", "2,1 km", "2,4 km", "2,6 km", "2,9 km", "3,2 km"];
|
||||
|
||||
function getMakerImage(maker: PublicMaker) {
|
||||
return maker.main_image_url || "/demo/maker-hero-1.svg";
|
||||
}
|
||||
|
||||
function readParam(value: string | string[] | undefined) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getRating(value: number | string) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getSatisfaction(value: number | string) {
|
||||
const rating = getRating(value);
|
||||
return `${Math.max(92, Math.round(rating * 20))}% satisfaccion`;
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string): HomeCategory {
|
||||
if (value === "print-3d" || value === "design-3d" || value === "resin" || value === "fdm") {
|
||||
return value;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function hasCategory(maker: PublicMaker, expected: string) {
|
||||
return (maker.categories || []).some((category) => category.toLowerCase() === expected.toLowerCase());
|
||||
}
|
||||
|
||||
function filterByCategory(makers: PublicMaker[], category: HomeCategory) {
|
||||
if (category === "all") {
|
||||
return makers;
|
||||
}
|
||||
|
||||
if (category === "print-3d") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion FDM") || hasCategory(maker, "Impresion Resina") || hasCategory(maker, "Prototipado"));
|
||||
}
|
||||
|
||||
if (category === "design-3d") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Diseno 3D"));
|
||||
}
|
||||
|
||||
if (category === "resin") {
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion Resina"));
|
||||
}
|
||||
|
||||
return makers.filter((maker) => hasCategory(maker, "Impresion FDM"));
|
||||
}
|
||||
|
||||
function orderMakers(makers: PublicMaker[]) {
|
||||
return [...makers].sort((left, right) => {
|
||||
const leftIndex = preferredOrder.indexOf(left.business_name);
|
||||
const rightIndex = preferredOrder.indexOf(right.business_name);
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
|
||||
}
|
||||
|
||||
return getRating(right.avg_rating) - getRating(left.avg_rating);
|
||||
});
|
||||
}
|
||||
|
||||
function getMakerTags(maker: PublicMaker) {
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const category of maker.categories || []) {
|
||||
if (category === "Impresion FDM") {
|
||||
tags.add("FDM");
|
||||
}
|
||||
if (category === "Impresion Resina") {
|
||||
tags.add("Resina");
|
||||
}
|
||||
if (category === "Diseno 3D") {
|
||||
tags.add("Diseno 3D");
|
||||
}
|
||||
if (category === "Prototipado") {
|
||||
tags.add("Prototipos");
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.size === 0) {
|
||||
tags.add("Impresion 3D");
|
||||
}
|
||||
|
||||
return Array.from(tags).slice(0, 3);
|
||||
}
|
||||
|
||||
function buildHomeHref(query: string, category: HomeCategory) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
}
|
||||
if (category !== "all") {
|
||||
params.set("category", category);
|
||||
}
|
||||
const serialized = params.toString();
|
||||
return serialized ? `/?${serialized}` : "/";
|
||||
}
|
||||
|
||||
function buildDiscoverHref(query: string, category: HomeCategory) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
}
|
||||
if (category === "design-3d") {
|
||||
params.set("serviceCategory", "Diseno 3D");
|
||||
}
|
||||
if (category === "resin") {
|
||||
params.set("serviceCategory", "Impresion Resina");
|
||||
}
|
||||
if (category === "fdm") {
|
||||
params.set("serviceCategory", "Impresion FDM");
|
||||
}
|
||||
const serialized = params.toString();
|
||||
return serialized ? `/discover?${serialized}` : "/discover";
|
||||
}
|
||||
|
||||
export async function MapHomeScreen({ searchParams = {} }: { searchParams?: SearchParams }) {
|
||||
const query = readParam(searchParams.q);
|
||||
const activeCategory = normalizeCategory(readParam(searchParams.category));
|
||||
|
||||
const requestParams = new URLSearchParams();
|
||||
if (query) {
|
||||
requestParams.set("q", query);
|
||||
}
|
||||
|
||||
const data = await serverFetch<{ makers: PublicMaker[] }>(`/makers?${requestParams.toString()}`);
|
||||
const makers = orderMakers(filterByCategory(data.makers, activeCategory));
|
||||
const visibleMakers = makers.slice(0, 10);
|
||||
const makerListItems: MapHomeMakerListItem[] = visibleMakers.map((maker, index) => ({
|
||||
id: maker.id,
|
||||
slug: maker.slug,
|
||||
name: maker.business_name,
|
||||
city: maker.city,
|
||||
province: maker.province,
|
||||
imageUrl: getMakerImage(maker),
|
||||
distance: distanceLabels[index % distanceLabels.length],
|
||||
rating: getRating(maker.avg_rating).toFixed(1),
|
||||
reviewCount: Math.max(maker.review_count * 32, 12),
|
||||
satisfaction: getSatisfaction(maker.avg_rating),
|
||||
tags: getMakerTags(maker)
|
||||
}));
|
||||
const discoverHref = buildDiscoverHref(query, activeCategory);
|
||||
const mapMarkers = makers
|
||||
.flatMap((maker) => {
|
||||
const publicLocations = (maker.public_locations || []).filter((location) => location.latitude !== null && location.longitude !== null);
|
||||
const locations = publicLocations.length
|
||||
? publicLocations
|
||||
: maker.public_latitude !== null && maker.public_longitude !== null
|
||||
? [{ id: maker.id, city: maker.city, province: maker.province, latitude: maker.public_latitude, longitude: maker.public_longitude }]
|
||||
: [];
|
||||
|
||||
return locations.map((location, index) => ({
|
||||
id: `${maker.id}:${location.id || index}`,
|
||||
slug: maker.slug,
|
||||
businessName: maker.business_name,
|
||||
city: location.city || maker.city,
|
||||
province: location.province || maker.province,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number
|
||||
}));
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="map-home-shell">
|
||||
<div className="map-home-device">
|
||||
<form action="/" className="map-home-search-stack">
|
||||
<div className="map-home-search-row">
|
||||
<label className="map-home-searchbar">
|
||||
<span className="map-home-search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="6.6" />
|
||||
<path d="m20 20-3.6-3.6" />
|
||||
</svg>
|
||||
</span>
|
||||
<input name="q" placeholder="Buscar makers, servicios, trabajos..." defaultValue={query} />
|
||||
</label>
|
||||
{activeCategory !== "all" ? <input type="hidden" name="category" value={activeCategory} /> : null}
|
||||
<a href={discoverHref} className="map-home-filter-link" aria-label="Abrir filtros">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="map-home-chip-row">
|
||||
<a href={discoverHref} className="map-home-chip map-home-chip-primary">Filtros</a>
|
||||
{quickCategories.map((category) => (
|
||||
<a
|
||||
key={category.id}
|
||||
href={buildHomeHref(query, category.id)}
|
||||
className={`map-home-chip ${activeCategory === category.id ? "active" : ""}`}
|
||||
>
|
||||
{category.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<InteractiveMakerMapClient markers={mapMarkers} />
|
||||
|
||||
<section className="map-home-results">
|
||||
<div className="map-home-results-head">
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<strong className="map-home-results-title">Makers cerca de ti</strong>
|
||||
<span className="mini-note">Cordoba, Argentina | 1,2 km</span>
|
||||
</div>
|
||||
<a href={discoverHref} className="map-home-view-all">Ver todos</a>
|
||||
</div>
|
||||
|
||||
<MapHomeMakerList makers={makerListItems} />
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type MakerProfile = {
|
||||
id: string;
|
||||
slug: string;
|
||||
business_name: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type MessageAccess = "customer" | "maker" | "admin";
|
||||
|
||||
type InquiryStatus = "open" | "agreed" | "completed" | "rejected" | "incident";
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
business_name: string;
|
||||
maker_slug: string;
|
||||
customer_name: string;
|
||||
customer_id: string;
|
||||
need_text: string;
|
||||
size_text: string | null;
|
||||
has_model: boolean;
|
||||
material_preference: string | null;
|
||||
quantity: number;
|
||||
urgency: string;
|
||||
delivery_type: string;
|
||||
source_type: string;
|
||||
status: InquiryStatus;
|
||||
rejection_reason: string | null;
|
||||
proposal_price_cents: number | null;
|
||||
proposal_quantity: number | null;
|
||||
proposal_lead_time_days: number | null;
|
||||
proposal_note: string | null;
|
||||
proposal_created_at: string | null;
|
||||
agreed_at: string | null;
|
||||
maker_completed_at: string | null;
|
||||
customer_completed_at: string | null;
|
||||
completed_at: string | null;
|
||||
incident_reason: string | null;
|
||||
incident_detail: string | null;
|
||||
last_message: string | null;
|
||||
last_message_at: string | null;
|
||||
last_sender_user_id: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
sender_user_id: string;
|
||||
full_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ConversationDetail = {
|
||||
inquiry: Conversation & {
|
||||
maker_user_id: string;
|
||||
customer_name: string;
|
||||
};
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
type FilterKey = "all" | "consultas" | "active";
|
||||
|
||||
const incidentOptions = [
|
||||
"El trabajo no fue entregado",
|
||||
"El resultado no fue el acordado",
|
||||
"Hubo un problema con el plazo",
|
||||
"El maker no responde",
|
||||
"Otro"
|
||||
];
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("es-AR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatMoney(value?: number | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `$${Math.round(value / 100).toLocaleString("es-AR")}`;
|
||||
}
|
||||
|
||||
function statusInfo(conversation: Conversation, userId?: string) {
|
||||
if (conversation.status === "completed") {
|
||||
return { label: "Finalizado", tone: "success", icon: "✓" };
|
||||
}
|
||||
if (conversation.status === "rejected") {
|
||||
return { label: "Rechazado", tone: "danger", icon: "×" };
|
||||
}
|
||||
if (conversation.status === "incident") {
|
||||
return { label: "Incidencia", tone: "danger", icon: "!" };
|
||||
}
|
||||
if (conversation.status === "agreed") {
|
||||
return { label: "Trabajo acordado", tone: "info", icon: "↔" };
|
||||
}
|
||||
if (conversation.message_count <= 1) {
|
||||
return { label: "Nueva consulta", tone: "info", icon: "✉" };
|
||||
}
|
||||
if (conversation.last_sender_user_id === userId) {
|
||||
return { label: "Esperando respuesta", tone: "warning", icon: "○" };
|
||||
}
|
||||
return { label: "En conversacion", tone: "success", icon: "●" };
|
||||
}
|
||||
|
||||
function deliveryLabel(value: string) {
|
||||
return value === "local" ? "Retiro / local" : "Envio";
|
||||
}
|
||||
|
||||
function urgencyLabel(value: string) {
|
||||
if (value === "high") {
|
||||
return "Urgente";
|
||||
}
|
||||
if (value === "low") {
|
||||
return "Sin urgencia";
|
||||
}
|
||||
return "Esta semana";
|
||||
}
|
||||
|
||||
export default function MessagesClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [messageAccess, setMessageAccess] = useState<MessageAccess>("customer");
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [reply, setReply] = useState("");
|
||||
const [review, setReview] = useState({ rating: 5, comment: "" });
|
||||
const [incident, setIncident] = useState({ reason: incidentOptions[0], detail: "" });
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
async function loadConversations(preferredId?: string, access = messageAccess) {
|
||||
if (access === "admin") {
|
||||
setConversations([]);
|
||||
setSelectedId("");
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = access === "maker" ? "maker" : "customer";
|
||||
const data = await browserFetch<{ conversations: Conversation[] }>(`/account/conversations?scope=${scope}`);
|
||||
setConversations(data.conversations);
|
||||
setSelectedId((current) => preferredId || current || data.conversations[0]?.id || "");
|
||||
}
|
||||
|
||||
async function refreshDetail(id = selectedId) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const data = await browserFetch<ConversationDetail>(`/inquiries/${id}`);
|
||||
setDetail(data);
|
||||
}
|
||||
|
||||
async function refreshAll(id = selectedId) {
|
||||
await Promise.all([loadConversations(id), refreshDetail(id)]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const me = await browserFetch<{ user: User | null; makerProfile: MakerProfile | null }>("/auth/me");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(me.user);
|
||||
const access: MessageAccess = me.user?.role === "admin"
|
||||
? "admin"
|
||||
: me.makerProfile && me.makerProfile.status !== "draft"
|
||||
? "maker"
|
||||
: "customer";
|
||||
setMessageAccess(access);
|
||||
if (me.user) {
|
||||
await loadConversations(undefined, access);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo cargar mensajes");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || !user) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
async function loadDetail() {
|
||||
try {
|
||||
const data = await browserFetch<ConversationDetail>(`/inquiries/${selectedId}`);
|
||||
if (active) {
|
||||
setDetail(data);
|
||||
setShowActions(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDetail();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedId, user]);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (filter === "consultas") {
|
||||
return conversations.filter((conversation) => conversation.status === "open");
|
||||
}
|
||||
if (filter === "active") {
|
||||
return conversations.filter((conversation) => ["open", "agreed", "incident"].includes(conversation.status));
|
||||
}
|
||||
return conversations;
|
||||
}, [conversations, filter]);
|
||||
|
||||
const accessCopy = messageAccess === "maker"
|
||||
? {
|
||||
eyebrow: "Consultas maker",
|
||||
title: "Bandeja de clientes",
|
||||
empty: "No hay consultas recibidas para este filtro.",
|
||||
auth: "Inicia sesion como maker para responder consultas de clientes."
|
||||
}
|
||||
: {
|
||||
eyebrow: "Mensajes",
|
||||
title: "Bandeja de consultas",
|
||||
empty: "No hay conversaciones para este filtro.",
|
||||
auth: "Inicia sesion para ver tus consultas y respuestas de los makers."
|
||||
};
|
||||
|
||||
async function sendReply(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const body = reply.trim();
|
||||
if (!selectedId || !body) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Enviando...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
setReply("");
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo enviar el mensaje");
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptProposal() {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Aceptando propuesta...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/accept-proposal`, { method: "POST" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo aceptar la propuesta");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCompleted() {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Confirmando finalizacion...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/complete`, { method: "POST" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo confirmar");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
setStatus("Publicando resena...");
|
||||
try {
|
||||
await browserFetch("/reviews", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
inquiryId: detail.inquiry.id,
|
||||
ratingOverall: review.rating,
|
||||
ratingQuality: review.rating,
|
||||
ratingCommunication: review.rating,
|
||||
ratingValue: review.rating,
|
||||
comment: review.comment
|
||||
})
|
||||
});
|
||||
setReview({ rating: 5, comment: "" });
|
||||
setStatus("Resena publicada.");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo publicar la resena");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitIncident(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
setStatus("Reportando incidencia...");
|
||||
try {
|
||||
await browserFetch(`/inquiries/${selectedId}/incident`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(incident)
|
||||
});
|
||||
setIncident({ reason: incidentOptions[0], detail: "" });
|
||||
setStatus("");
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo reportar la incidencia");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-loading">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-auth-card">
|
||||
<h1>Mensajes</h1>
|
||||
<p>{accessCopy.auth}</p>
|
||||
<Link href="/login?returnTo=%2Fmessages" className="button button-primary">Entrar</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageAccess === "admin") {
|
||||
return (
|
||||
<section className="messages-shell">
|
||||
<div className="messages-auth-card">
|
||||
<span className="eyebrow">Mensajes administrador</span>
|
||||
<h1>Bandeja administrativa</h1>
|
||||
<p>Las consultas operativas, incidencias y reportes del administrador se gestionan desde el backoffice para mantener trazabilidad.</p>
|
||||
<div className="home-actions">
|
||||
<a href="/app/backoffice/cases" className="button button-primary">Abrir casos</a>
|
||||
<a href="/app/backoffice" className="button button-secondary">Ir al backoffice</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const current = detail?.inquiry;
|
||||
const currentInfo = current ? statusInfo(current, user.id) : null;
|
||||
const currentDisplayName = current
|
||||
? messageAccess === "maker" ? current.customer_name : current.business_name
|
||||
: "";
|
||||
const whatsappText = current
|
||||
? encodeURIComponent(`Hola, soy ${detail.inquiry.customer_name}. Te contacto por la consulta "${current.need_text}".`)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section className="messages-shell messages-flow-shell">
|
||||
<aside className="messages-list">
|
||||
<div className="messages-head">
|
||||
<div>
|
||||
<span className="eyebrow">{accessCopy.eyebrow}</span>
|
||||
<h1>{accessCopy.title}</h1>
|
||||
</div>
|
||||
<span className="badge">{conversations.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="messages-filter-row" aria-label="Filtros de mensajes">
|
||||
{[
|
||||
["all", "Todos"],
|
||||
["consultas", "Consultas"],
|
||||
["active", "En curso"]
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`messages-filter-chip ${filter === key ? "is-active" : ""}`}
|
||||
onClick={() => setFilter(key as FilterKey)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="messages-conversation-list">
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="empty-state">{accessCopy.empty}</div>
|
||||
) : null}
|
||||
|
||||
{filteredConversations.map((conversation) => {
|
||||
const info = statusInfo(conversation, user.id);
|
||||
const displayName = messageAccess === "maker" ? conversation.customer_name : conversation.business_name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={`messages-conversation ${conversation.id === selectedId ? "is-active" : ""}`}
|
||||
onClick={() => setSelectedId(conversation.id)}
|
||||
>
|
||||
<span className="messages-avatar">{displayName.slice(0, 2).toUpperCase()}</span>
|
||||
<span className="messages-conversation-copy">
|
||||
<strong>{displayName}</strong>
|
||||
<span>{conversation.last_message || conversation.need_text}</span>
|
||||
<span className={`messages-state-text is-${info.tone}`}>{info.icon} {info.label}</span>
|
||||
</span>
|
||||
<span className="messages-time">{formatTime(conversation.last_message_at || conversation.created_at)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="messages-thread">
|
||||
{current && detail ? (
|
||||
<>
|
||||
<div className="messages-thread-head">
|
||||
<div className="messages-maker-title">
|
||||
<span className="messages-avatar small">{currentDisplayName.slice(0, 2).toUpperCase()}</span>
|
||||
<div>
|
||||
<h2>{currentDisplayName}</h2>
|
||||
<span className={`messages-state-text is-${currentInfo?.tone}`}>{currentInfo?.icon} {currentInfo?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="messages-icon-button" type="button" onClick={() => setShowActions((value) => !value)} aria-label="Mas acciones">
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<article className="messages-context-card">
|
||||
<div className="messages-context-main">
|
||||
<span className="eyebrow">Consulta estructurada</span>
|
||||
<strong>{current.need_text}</strong>
|
||||
<Link href={`/makers/${current.maker_slug}`}>Ver perfil del maker</Link>
|
||||
</div>
|
||||
<div className="messages-context-grid">
|
||||
<span>Cantidad <strong>{current.quantity || 1}</strong></span>
|
||||
<span>Urgencia <strong>{urgencyLabel(current.urgency)}</strong></span>
|
||||
<span>Entrega <strong>{deliveryLabel(current.delivery_type)}</strong></span>
|
||||
<span>Material <strong>{current.material_preference || "A definir"}</strong></span>
|
||||
<span>Medidas <strong>{current.size_text || "Sin medidas"}</strong></span>
|
||||
<span>Adjuntos <strong>{current.has_model ? "Con archivo" : "Sin archivo"}</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{showActions ? (
|
||||
<div className="messages-actions-panel">
|
||||
<button type="button" onClick={() => setReply((value) => value || "Te comparto las medidas actualizadas.")}>Compartir medidas</button>
|
||||
<button type="button" onClick={() => setReply((value) => value || "Adjunto fotos de referencia para que lo revises.")}>Anadir fotos</button>
|
||||
<a href={`https://wa.me/?text=${whatsappText}`} target="_blank" rel="noreferrer">Continuar por WhatsApp</a>
|
||||
<a href={`mailto:?subject=Consulta Makers3D&body=${whatsappText}`}>Continuar por email</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="messages-bubbles">
|
||||
{detail.messages.map((message) => {
|
||||
const isMine = message.sender_user_id === user.id;
|
||||
|
||||
return (
|
||||
<article key={message.id} className={`messages-bubble ${isMine ? "is-mine" : "is-maker"}`}>
|
||||
<div className="messages-bubble-top">
|
||||
<strong>{isMine ? "Tu" : currentDisplayName}</strong>
|
||||
<span>{formatTime(message.created_at)}</span>
|
||||
</div>
|
||||
<p>{message.body}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{current.proposal_price_cents ? (
|
||||
<article className="messages-system-card">
|
||||
<span className="messages-system-icon">↔</span>
|
||||
<div>
|
||||
<strong>{current.status === "agreed" || current.status === "completed" ? "Trabajo acordado" : "Propuesta de trabajo"}</strong>
|
||||
<dl>
|
||||
<div><dt>Trabajo</dt><dd>{current.need_text}</dd></div>
|
||||
<div><dt>Cantidad</dt><dd>{current.proposal_quantity || current.quantity || 1}</dd></div>
|
||||
<div><dt>Precio</dt><dd>{formatMoney(current.proposal_price_cents)}</dd></div>
|
||||
<div><dt>Plazo</dt><dd>{current.proposal_lead_time_days} dias</dd></div>
|
||||
</dl>
|
||||
{current.proposal_note ? <p>{current.proposal_note}</p> : null}
|
||||
{current.status === "open" ? (
|
||||
<button className="button button-primary" type="button" onClick={acceptProposal}>Aceptar propuesta</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "completed" ? (
|
||||
<article className="messages-system-card success">
|
||||
<span className="messages-system-icon">✓</span>
|
||||
<div>
|
||||
<strong>Trabajo finalizado</strong>
|
||||
<p>Ambas partes confirmaron que el trabajo fue completado.</p>
|
||||
<small>Finalizado el {formatTime(current.completed_at)}</small>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "rejected" ? (
|
||||
<article className="messages-system-card danger">
|
||||
<span className="messages-system-icon">×</span>
|
||||
<div>
|
||||
<strong>Consulta rechazada</strong>
|
||||
<p>{current.rejection_reason || "El maker no puede realizar este trabajo."}</p>
|
||||
<Link href="/discover" className="button button-primary">Ver mas alternativas</Link>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{current.status === "incident" ? (
|
||||
<article className="messages-system-card danger">
|
||||
<span className="messages-system-icon">!</span>
|
||||
<div>
|
||||
<strong>Incidencia abierta</strong>
|
||||
<p>{current.incident_reason}: {current.incident_detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="messages-work-actions">
|
||||
{current.status === "agreed" || current.status === "open" ? (
|
||||
<button className="button button-secondary" type="button" onClick={confirmCompleted}>
|
||||
Marcar como finalizado
|
||||
</button>
|
||||
) : null}
|
||||
{current.status !== "incident" && current.status !== "rejected" ? (
|
||||
<button className="button button-secondary" type="button" onClick={() => setIncident((value) => ({ ...value, detail: value.detail || "Necesito ayuda con esta consulta." }))}>
|
||||
Reportar problema
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{current.status === "completed" ? (
|
||||
<form className="messages-review-box" onSubmit={submitReview}>
|
||||
<strong>Dejar resena verificada</strong>
|
||||
<div className="messages-stars" aria-label="Puntuacion">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button key={star} type="button" className={review.rating >= star ? "is-active" : ""} onClick={() => setReview((value) => ({ ...value, rating: star }))}>★</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea value={review.comment} onChange={(event) => setReview((value) => ({ ...value, comment: event.target.value }))} placeholder="Cuenta como fue tu experiencia..." required minLength={10} />
|
||||
<button className="button button-primary" type="submit">Publicar resena</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{incident.detail ? (
|
||||
<form className="messages-incident-box" onSubmit={submitIncident}>
|
||||
<strong>Cuentanos que ocurrio</strong>
|
||||
<select value={incident.reason} onChange={(event) => setIncident((value) => ({ ...value, reason: event.target.value }))}>
|
||||
{incidentOptions.map((option) => <option key={option}>{option}</option>)}
|
||||
</select>
|
||||
<textarea value={incident.detail} onChange={(event) => setIncident((value) => ({ ...value, detail: event.target.value }))} placeholder="Danos mas detalles..." required minLength={5} />
|
||||
<button className="button button-primary" type="submit">Enviar reporte</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<form className="messages-composer" onSubmit={sendReply}>
|
||||
<button type="button" className="messages-plus-button" onClick={() => setShowActions((value) => !value)} aria-label="Acciones">+</button>
|
||||
<input
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="Escribe un mensaje..."
|
||||
aria-label="Mensaje"
|
||||
/>
|
||||
<button className="button button-primary" type="submit">Enviar</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">Selecciona una conversacion para ver el detalle.</div>
|
||||
)}
|
||||
|
||||
{status ? <p className="messages-status">{status}</p> : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type DockItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
isActive: (pathname: string) => boolean;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
function DockIcon({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="mobile-link-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const dockItems: DockItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Mapa",
|
||||
isActive: (pathname) => pathname === "/",
|
||||
icon: <DockIcon><path d="M12 20s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z" /><circle cx="12" cy="10" r="2.2" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/discover",
|
||||
label: "Descubrir",
|
||||
isActive: (pathname) => pathname === "/discover" || pathname === "/results",
|
||||
icon: <DockIcon><circle cx="11" cy="11" r="6.3" /><path d="m20 20-3.4-3.4" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
label: "Mensajes",
|
||||
isActive: (pathname) => pathname.startsWith("/messages") || pathname.startsWith("/account/inbox"),
|
||||
icon: <DockIcon><path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7A2.5 2.5 0 0 1 17.5 16H10l-4.5 4v-4H6.5A2.5 2.5 0 0 1 4 13.5v-7Z" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/favorites",
|
||||
label: "Favoritos",
|
||||
isActive: (pathname) => pathname.startsWith("/favorites"),
|
||||
icon: <DockIcon><path d="m12 20-1.35-1.23C5.4 14 2 10.92 2 7.1 2 4.48 4.07 2.5 6.6 2.5c1.64 0 3.22.83 4.2 2.14A5.16 5.16 0 0 1 15 2.5c2.53 0 4.6 1.98 4.6 4.6 0 3.82-3.4 6.9-8.65 11.67L12 20Z" /></DockIcon>
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
label: "Perfil",
|
||||
isActive: (pathname) => pathname.startsWith("/profile") || pathname === "/account",
|
||||
icon: <DockIcon><path d="M19 20a7 7 0 0 0-14 0" /><circle cx="12" cy="8" r="4" /></DockIcon>
|
||||
}
|
||||
];
|
||||
|
||||
export default function MobileAppDock() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (
|
||||
pathname.startsWith("/app/backoffice")
|
||||
|| pathname.startsWith("/admin")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="mobile-dock" aria-label="Navegacion principal movil">
|
||||
{dockItems.map((item) => (
|
||||
<a key={item.href} href={item.href} className={`mobile-link ${item.isActive(pathname) ? "active" : ""}`}>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { logoutByNavigation } from "../lib/logout";
|
||||
|
||||
type MeResponse = {
|
||||
user: { id: string; email: string; role: string } | null;
|
||||
makerProfile: { id: string; slug: string; business_name: string | null; status: string } | null;
|
||||
};
|
||||
|
||||
export default function ProfileClient() {
|
||||
const [session, setSession] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const response = await browserFetch<MeResponse>("/auth/me");
|
||||
if (active) {
|
||||
setSession(response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setStatus(error instanceof Error ? error.message : "No se pudo cargar la sesion");
|
||||
setSession({ user: null, makerProfile: null });
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.role === "admin") {
|
||||
window.location.replace("/app/backoffice");
|
||||
}
|
||||
}, [session?.user?.role]);
|
||||
|
||||
function logout() {
|
||||
logoutByNavigation();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<h1 className="section-title">Cargando perfil</h1>
|
||||
<p className="muted">Validando tu sesion para mostrar el acceso correcto.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<h1 className="section-title">Necesitas iniciar sesion</h1>
|
||||
<p className="muted">Entra con un usuario demo para probar cliente, maker o administrador.</p>
|
||||
<Link href="/login?returnTo=%2Fprofile" className="button button-primary">Entrar</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.user.role === "admin") {
|
||||
return (
|
||||
<section className="workspace-main-card stack">
|
||||
<span className="eyebrow">Backoffice</span>
|
||||
<h1 className="section-title">Redirigiendo al panel administrador</h1>
|
||||
<p className="muted">La cuenta superadmin no usa perfil maker ni perfil cliente.</p>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.makerProfile && session.makerProfile.status !== "draft") {
|
||||
const makerName = session.makerProfile.business_name || "Tu espacio Maker";
|
||||
|
||||
return (
|
||||
<section className="profile-account-card stack">
|
||||
<div className="profile-identity">
|
||||
<span className="profile-avatar">{makerName.slice(0, 2).toUpperCase()}</span>
|
||||
<div>
|
||||
<span className="eyebrow">Perfil maker</span>
|
||||
<h1 className="section-title">{makerName}</h1>
|
||||
<p className="muted">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-status-grid">
|
||||
<div className="stat-tile"><span className="stat-value">Activo</span><span className="mini-note">Estado de cuenta</span></div>
|
||||
<div className="stat-tile"><span className="stat-value">{session.makerProfile.status}</span><span className="mini-note">Perfil publico</span></div>
|
||||
</div>
|
||||
<div className="profile-action-grid">
|
||||
<Link href="/account" className="profile-action-tile">
|
||||
<strong>Mi espacio maker</strong>
|
||||
<span>Dashboard, trabajos, servicios y reputacion.</span>
|
||||
</Link>
|
||||
<Link href="/account/inbox" className="profile-action-tile">
|
||||
<strong>Consultas recibidas</strong>
|
||||
<span>Mensajes de clientes y propuestas de trabajo.</span>
|
||||
</Link>
|
||||
<Link href={`/makers/${session.makerProfile.slug}`} className="profile-action-tile">
|
||||
<strong>Ver perfil publico</strong>
|
||||
<span>Asi te ven clientes y proveedores.</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="profile-action-tile">
|
||||
<strong>Favoritos</strong>
|
||||
<span>Makers y trabajos que guardaste.</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="profile-account-card stack client-profile-card">
|
||||
<div className="profile-identity">
|
||||
<span className="profile-avatar">CL</span>
|
||||
<div>
|
||||
<span className="eyebrow">Perfil cliente</span>
|
||||
<h1 className="section-title">Tu cuenta de cliente</h1>
|
||||
<p className="muted">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-action-grid">
|
||||
<Link href="/messages" className="profile-action-tile">
|
||||
<strong>Mensajes</strong>
|
||||
<span>Tus consultas enviadas a makers y sus respuestas.</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="profile-action-tile">
|
||||
<strong>Favoritos</strong>
|
||||
<span>Makers y trabajos guardados para revisar despues.</span>
|
||||
</Link>
|
||||
<Link href="/account" className="profile-action-tile">
|
||||
<strong>Crear mi espacio maker</strong>
|
||||
<span>Solo si tambien queres publicar servicios como maker.</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button className="button button-secondary" type="button" onClick={logout}>Cerrar sesion</button>
|
||||
{status ? <p className="muted">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { browserFetch } from "../lib/api";
|
||||
import { notifyAuthChanged } from "../lib/authEvents";
|
||||
|
||||
export default function RegisterForm({ redirectTo = "/account" }: { redirectTo?: string }) {
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ fullName: "", email: "", password: "" });
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await browserFetch("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
notifyAuthChanged("login");
|
||||
window.location.href = redirectTo;
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "No se pudo crear la cuenta");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={onSubmit}>
|
||||
<input className="field" placeholder="Nombre completo" value={form.fullName} onChange={(event) => setForm((current) => ({ ...current, fullName: event.target.value }))} required />
|
||||
<input className="field" type="email" placeholder="Correo" value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} required />
|
||||
<input className="field" type="password" placeholder="Contrasena" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} required />
|
||||
<button className="button button-primary" type="submit">Crear cuenta</button>
|
||||
{error ? <p className="muted">{error}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// The app works without offline support, so registration failures stay silent in the demo MVP.
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||