Modulo makers3d desarrollado con codex V 0.0.1

This commit is contained in:
Ryuk Mike
2026-07-29 23:58:58 +02:00
commit 2bedf7cbb7
171 changed files with 29421 additions and 0 deletions
+19
View File
@@ -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"]
+30
View File
@@ -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"
}
}
+100
View File
@@ -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;
}
+33
View File
@@ -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);
+18
View File
@@ -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;
}
+277
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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);
});
+443
View File
@@ -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
);
}
+31
View File
@@ -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();
+403
View File
@@ -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();
+34
View File
@@ -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);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true
},
"include": ["src/**/*.ts"]
}
+19
View File
@@ -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"]
+15
View File
@@ -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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountInboxPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="inbox" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountLocationsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="locations" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../components/AccountClient";
export default function AccountPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="summary" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountProfilePage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="profile" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountReviewsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="reviews" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountServicesPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="services" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountSettingsPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="settings" />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AccountClient from "../../components/AccountClient";
export default function AccountShowcasesPage() {
return (
<main className="page-shell workspace-page">
<AccountClient section="showcases" />
</main>
);
}
+9
View File
@@ -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>
);
}
+9
View File
@@ -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} />;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92
View File
@@ -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}
/>
);
}
+125
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+132
View File
@@ -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="&copy; OpenStreetMap contributors &copy; 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='&copy; OpenStreetMap contributors &copy; 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} />;
}
+35
View File
@@ -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>
);
}
+268
View File
@@ -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>
);
}
+637
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+36
View File
@@ -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;
}
@@ -0,0 +1,93 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import FavoriteToggle from "./FavoriteToggle";
type WorkHeroGalleryProps = {
gallery: string[];
title: string;
workId: string;
shareUrl?: string;
};
export default function WorkHeroGallery({ gallery, title, workId, shareUrl }: WorkHeroGalleryProps) {
const router = useRouter();
const galleryRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
function updateActiveSlide() {
const galleryNode = galleryRef.current;
if (!galleryNode) {
return;
}
const nextIndex = Math.round(galleryNode.scrollLeft / Math.max(galleryNode.clientWidth, 1));
setActiveIndex(Math.min(Math.max(nextIndex, 0), gallery.length - 1));
}
function goBack() {
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
return;
}
router.push("/discover");
}
async function shareWork() {
const url = shareUrl || (typeof window !== "undefined" ? window.location.href : "");
if (!url) {
return;
}
if (typeof navigator !== "undefined" && navigator.share) {
await navigator.share({
title,
text: `Mira este trabajo que encontre en Makers3D: ${title}`,
url
}).catch(() => undefined);
return;
}
await navigator.clipboard?.writeText(url).catch(() => undefined);
}
return (
<section className="work-detail-hero">
<div ref={galleryRef} className="work-detail-hero-gallery" onScroll={updateActiveSlide} aria-label={`Galeria de ${title}`}>
{gallery.map((image, index) => (
<div key={`${image}-${index}`} className="work-detail-hero-slide">
<img src={image} alt={`${title} - foto ${index + 1}`} />
</div>
))}
</div>
{gallery.length > 1 ? (
<div className="work-detail-hero-dots" aria-hidden="true">
{gallery.map((image, index) => (
<span key={`${image}-dot-${index}`} className={index === activeIndex ? "is-active" : ""} />
))}
</div>
) : null}
<div className="work-detail-top-actions">
<button className="work-detail-circle-action" type="button" onClick={goBack} aria-label="Volver">&lt;</button>
<div className="work-detail-action-pair">
<FavoriteToggle targetType="work" targetId={workId} compact icon="bookmark" />
<button className="work-detail-circle-action" type="button" onClick={() => void shareWork()} aria-label="Compartir trabajo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4" />
<path d="m8.6 13.5 6.8 4" />
</svg>
</button>
</div>
</div>
<span className="work-detail-gallery-count">{activeIndex + 1}/{gallery.length}</span>
</section>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useMemo, useState } from "react";
type WorkShareActionsProps = {
title: string;
url: string;
};
export default function WorkShareActions({ title, url }: WorkShareActionsProps) {
const [status, setStatus] = useState("");
const shareText = useMemo(
() => `Mira esto que encontre en Makers3D: ${title}`,
[title]
);
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(`${shareText}\n${url}`)}`;
async function copyLink() {
try {
await navigator.clipboard.writeText(url);
setStatus("Enlace copiado.");
} catch {
setStatus("No se pudo copiar. Manten presionado el enlace.");
}
}
async function shareWork() {
if (navigator.share) {
try {
await navigator.share({
title,
text: shareText,
url
});
setStatus("Listo para compartir.");
return;
} catch (error) {
const shareError = error as Error;
if (shareError.name === "AbortError") {
return;
}
}
}
await copyLink();
}
return (
<div className="work-detail-share-actions">
<button type="button" className="button button-primary" onClick={shareWork}>
Compartir
</button>
<a className="work-detail-share-option" href={whatsappHref} target="_blank" rel="noreferrer">
WhatsApp
</a>
<button type="button" className="work-detail-share-option" onClick={copyLink}>
Copiar enlace
</button>
{status ? <span className="work-detail-share-status">{status}</span> : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import InquiryWizardClient from "../../components/InquiryWizardClient";
export default async function NewInquiryPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const makerId = typeof params?.makerId === "string" ? params.makerId : "";
const sourceType = typeof params?.sourceType === "string" ? params.sourceType as "profile" | "service" | "work" : "profile";
const sourceId = typeof params?.sourceId === "string" ? params.sourceId : "";
const makerName = typeof params?.makerName === "string" ? params.makerName : "Maker";
const contextTitle = typeof params?.contextTitle === "string" ? params.contextTitle : "Consulta nueva";
const returnTo = typeof params?.returnTo === "string" ? params.returnTo : "/";
return (
<main className="page-shell wizard-page">
<InquiryWizardClient
makerId={makerId}
sourceType={sourceType}
sourceId={sourceId || undefined}
makerName={makerName}
contextTitle={contextTitle}
returnTo={returnTo}
/>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DiscoverResultsScreen } from "../components/DiscoverResultsScreen";
export default async function DiscoverPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell discover-page">
<DiscoverResultsScreen searchParams={params} />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import FavoritesClient from "../components/FavoritesClient";
export default function FavoritesPage() {
return (
<main className="page-shell favorites-page">
<FavoritesClient />
</main>
);
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata, Viewport } from "next";
import Link from "next/link";
import { Space_Grotesk, Manrope } from "next/font/google";
import AuthNav from "./components/AuthNav";
import AuthSessionSync from "./components/AuthSessionSync";
import MobileAppDock from "./components/MobileAppDock";
import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister";
import "leaflet/dist/leaflet.css";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-head"
});
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-body"
});
export const metadata: Metadata = {
title: "Makers3D",
description: "Descubre, evalua y contacta makers 3D en Argentina.",
applicationName: "Makers3D",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Makers3D"
},
icons: {
icon: "/icon.svg",
apple: "/icon.svg"
}
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#09111d"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" className={`${spaceGrotesk.variable} ${manrope.variable}`}>
<body>
<ServiceWorkerRegister />
<AuthSessionSync />
<header className="topbar">
<div className="topbar-inner">
<Link href="/" className="brand">
<span className="brand-mark" />
<span>
Makers3D
<span className="brand-note">MVP operativo</span>
</span>
</Link>
<div className="topbar-search">
<span>Buscar maker, trabajo, servicio o incidencia...</span>
<span>CTRL K</span>
</div>
<AuthNav />
</div>
</header>
{children}
<MobileAppDock />
</body>
</html>
);
}
+37
View File
@@ -0,0 +1,37 @@
export const browserApiBase = process.env.NEXT_PUBLIC_API_BASE_URL || "/api/v1";
const internalApiBase = process.env.INTERNAL_API_URL || "http://api:4000/api/v1";
export async function serverFetch<T>(path: string): Promise<T> {
const response = await fetch(`${internalApiBase}${path}`, {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function browserFetch<T>(path: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
const isFormData = typeof FormData !== "undefined" && options?.body instanceof FormData;
if (options?.body && !isFormData && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${browserApiBase}${path}`, {
...options,
credentials: "include",
cache: "no-store",
headers
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || `Request failed: ${response.status}`) as Error & { status?: number };
error.status = response.status;
throw error;
}
return data as T;
}
+69
View File
@@ -0,0 +1,69 @@
const authChannelName = "makers3d-auth";
const authTabIdKey = "makers3d-auth-tab";
export type AuthEventType = "login" | "logout";
function getTabId() {
let tabId = window.sessionStorage.getItem(authTabIdKey);
if (!tabId) {
tabId = crypto.randomUUID();
window.sessionStorage.setItem(authTabIdKey, tabId);
}
return tabId;
}
export function notifyAuthChanged(type: AuthEventType) {
if (typeof window === "undefined") {
return;
}
const payload = JSON.stringify({ type, source: getTabId(), at: Date.now() });
if ("BroadcastChannel" in window) {
const channel = new BroadcastChannel(authChannelName);
channel.postMessage(payload);
channel.close();
}
window.localStorage.setItem(authChannelName, payload);
}
export function listenAuthChanged(callback: (type: AuthEventType) => void) {
if (typeof window === "undefined") {
return () => {};
}
function handlePayload(payload: unknown) {
if (typeof payload !== "string") {
return;
}
try {
const event = JSON.parse(payload) as { type?: AuthEventType; source?: string };
if (event.source === getTabId()) {
return;
}
if (event.type === "login" || event.type === "logout") {
callback(event.type);
}
} catch {
// Ignore malformed cross-tab payloads.
}
}
const channel = "BroadcastChannel" in window ? new BroadcastChannel(authChannelName) : null;
channel?.addEventListener("message", (event) => handlePayload(event.data));
const storageListener = (event: StorageEvent) => {
if (event.key === authChannelName) {
handlePayload(event.newValue);
}
};
window.addEventListener("storage", storageListener);
return () => {
channel?.close();
window.removeEventListener("storage", storageListener);
};
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { browserApiBase } from "./api";
import { notifyAuthChanged } from "./authEvents";
export function logoutByNavigation(redirectTo = "/login") {
notifyAuthChanged("logout");
window.location.assign(`${browserApiBase}/auth/logout?redirect=${encodeURIComponent(redirectTo)}`);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import LoginForm from "../components/LoginForm";
export default async function LoginPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const redirectTo = typeof params?.returnTo === "string" ? params.returnTo : "/account";
return (
<main className="page-shell">
<div className="auth-wrap">
<section className="auth-card">
<span className="eyebrow">Acceso</span>
<h1 className="section-title">Entrar en Makers3D</h1>
<p className="muted">Usa una cuenta demo para revisar la experiencia publica, el panel maker y el backoffice.</p>
<LoginForm redirectTo={redirectTo} />
<p className="muted">Si no tienes cuenta, <Link href={`/register?returnTo=${encodeURIComponent(redirectTo)}`}>creala aqui</Link>.</p>
</section>
</div>
</main>
);
}
+285
View File
@@ -0,0 +1,285 @@
import Link from "next/link";
import FavoriteToggle from "../../components/FavoriteToggle";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function toNumber(value: unknown, fallback = 0) {
return typeof value === "number" ? value : Number(value) || fallback;
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function availabilityLabel(value: string) {
if (value === "available") {
return "Aceptando trabajos";
}
if (value === "limited") {
return "Disponibilidad limitada";
}
if (value === "unavailable") {
return "No acepta trabajos";
}
return value || "Aceptando trabajos";
}
function workImage(work: Record<string, unknown> | undefined, fallback = "/demo/work-custom.svg") {
return work ? toText(work.image_url, fallback) : fallback;
}
export default async function MakerPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const data = await serverFetch<{
maker: Record<string, unknown>;
services: Array<Record<string, unknown>>;
showcases: Array<Record<string, unknown>>;
works: Array<Record<string, unknown>>;
reviews: Array<Record<string, unknown>>;
}>(`/makers/${slug}`);
const { maker, services, showcases, works, reviews } = data;
const makerName = toText(maker.business_name, "Maker");
const makerImage = toText(maker.main_image_url, workImage(works[0], "/demo/maker-hero-1.svg"));
const rating = toNumber(maker.avg_rating, 4.9).toFixed(1);
const allowVerifiedReviews = maker.allow_verified_reviews !== false;
const showSatisfactionScore = maker.show_satisfaction_score !== false;
const showReviewTags = maker.show_review_tags !== false;
const showPastReviews = maker.show_past_reviews !== false;
const visibleReviews = showPastReviews ? reviews : [];
const reviewCount = showPastReviews ? Math.max(toNumber(maker.review_count, reviews.length), reviews.length) : 0;
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(maker.id))}&sourceType=profile&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(makerName)}&returnTo=${encodeURIComponent(`/makers/${slug}`)}`;
const tabs = [
"Destacado",
`Trabajos (${works.length})`,
`Servicios (${services.length})`,
`Escaparates (${showcases.length})`,
`Opiniones (${visibleReviews.length})`,
"Informacion"
];
const statCards = [
{ value: rating, label: "Reputacion", icon: "*" },
...(showSatisfactionScore ? [{ value: "96%", label: "Satisfaccion", icon: "ok" }] : []),
{ value: "~1 h", label: "Respuesta", icon: "rz" },
{ value: "Alta", label: "Confianza", icon: "cf" }
];
const publicBadges = [
...(reviewCount ? ["Trabajo verificado"] : []),
...(reviewCount >= 2 ? ["Cliente recurrente"] : []),
"Respuesta rapida",
"Confianza + Transparencia"
];
const socialLinks = [
["Web", toText(maker.website_url)],
["Instagram", toText(maker.instagram_url)],
["TikTok", toText(maker.tiktok_url)],
["X", toText(maker.twitter_url)],
["Facebook", toText(maker.facebook_url)],
["YouTube", toText(maker.youtube_url)],
["LinkedIn", toText(maker.linkedin_url)]
].filter(([, href]) => href);
return (
<main className="page-shell maker-public-page">
<section className="maker-public-phone">
<section className="maker-public-hero">
<img src={makerImage} alt={makerName} />
<div className="maker-public-top-actions">
<Link href="/discover" className="maker-public-circle-action" aria-label="Volver a descubrir">&lt;</Link>
<div className="maker-public-action-pair">
<FavoriteToggle targetType="maker" targetId={String(maker.id)} targetSlug={slug} compact />
<button className="maker-public-circle-action" type="button" aria-label="Compartir">sh</button>
</div>
</div>
<span className="maker-public-gallery-count">1/6</span>
</section>
<section className="maker-public-main-card">
<div className="maker-public-identity">
<div className="maker-public-logo">
<span>{makerName.slice(0, 2).toUpperCase()}</span>
</div>
<div className="maker-public-title-block">
<div className="maker-public-name-row">
<h1>{makerName}</h1>
<span className="maker-public-online-dot" />
<span className="maker-public-availability">{availabilityLabel(toText(maker.availability, "available"))}</span>
</div>
<div className="maker-public-rating">
<span className="map-home-star">*</span>
<strong>{rating}</strong>
<span>({reviewCount} resenas verificadas)</span>
</div>
<p>{toText(maker.city, "Cordoba")}, {toText(maker.province, "Argentina")} | 1,2 km</p>
</div>
</div>
<div className="maker-public-stats">
{statCards.map((stat) => (
<article key={stat.label}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
<small>{stat.icon}</small>
</article>
))}
</div>
{showReviewTags ? (
<div className="maker-public-trust-badges">
{publicBadges.map((badge) => <span key={badge}>{badge}</span>)}
</div>
) : null}
<div className="maker-public-tags">
{services.slice(0, 5).map((service) => (
<span key={String(service.id)}>{toText(service.category, "Servicio")}</span>
))}
{services.length === 0 ? <span>Impresion 3D</span> : null}
</div>
<div className="maker-public-actions-row">
<a href={contactHref} className="button button-primary">Escribir al maker</a>
<a href={`https://wa.me/${toText(maker.public_whatsapp, "")}`} className="maker-public-whatsapp" aria-label="WhatsApp">wa</a>
</div>
<section className="maker-public-recommendation">
<span className="maker-public-section-icon">i</span>
<div>
<strong>Por que te lo recomendamos?</strong>
<ul>
<li>A 1,2 km de tu ubicacion</li>
<li>Especialista en trabajos funcionales</li>
<li>{works.length} trabajos verificados</li>
<li>Excelente calidad/precio</li>
</ul>
</div>
</section>
</section>
<nav className="maker-public-tabs" aria-label="Secciones del perfil">
{tabs.map((tab, index) => (
<a key={tab} href={index === 0 ? "#destacado" : index === 1 ? "#trabajos" : index === 2 ? "#servicios" : index === 3 ? "#escaparates" : index === 4 ? "#opiniones" : "#informacion"}>
{tab}
</a>
))}
</nav>
<section id="destacado" className="maker-public-section">
<span className="eyebrow">Destacado</span>
<h2>Especialidades</h2>
<div className="maker-public-chip-grid">
{["Repuestos", "Ingenieria", "Automotor", "Diseno CAD", "Prototipos"].map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
<section className="maker-public-section">
<div className="maker-public-section-head">
<h2>Escaparates</h2>
<Link href="#escaparates">Ver todos</Link>
</div>
<div id="escaparates" className="maker-public-showcases">
{showcases.slice(0, 4).map((showcase) => (
<Link key={String(showcase.id)} href={`/showcases/${toText(showcase.slug)}`} style={{ backgroundImage: `url(${toText(showcase.cover_image_url, workImage(works[0], "/demo/feed-print-technical.svg"))})` }}>
<strong>{toText(showcase.title, "Escaparate")}</strong>
<span>{Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids.length : 0} trabajos</span>
</Link>
))}
{showcases.length === 0 ? (
<Link href={works[0] ? `/works/${toText(works[0].slug)}` : "#"} style={{ backgroundImage: `url(${workImage(works[0], "/demo/feed-print-technical.svg")})` }}>
<strong>Trabajos destacados</strong>
<span>{Math.max(works.length, 1)} trabajos</span>
</Link>
) : null}
</div>
</section>
<section id="trabajos" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Trabajos destacados</h2>
<Link href="/discover">Ver todos</Link>
</div>
<div className="maker-public-work-rail">
{works.map((work) => (
<Link key={String(work.id)} href={`/works/${toText(work.slug)}`} className="maker-public-work-card">
<img src={workImage(work)} alt={toText(work.title)} />
<strong>{toText(work.title)}</strong>
<span>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</span>
<small><span className="map-home-star">*</span> {rating}</small>
</Link>
))}
</div>
</section>
<section id="servicios" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Servicios y precios</h2>
<Link href="#servicios">Ver todos</Link>
</div>
<div className="maker-public-service-list">
{services.map((service) => (
<Link key={String(service.id)} href={`/services/${toText(service.slug)}`}>
<img src={workImage(works[0], "/demo/feed-print-gear.svg")} alt={toText(service.title)} />
<div>
<strong>{toText(service.title)}</strong>
<span>{asArray(service.materials).slice(0, 3).join(" | ") || toText(service.category)}</span>
<small>Desde ${Math.round(toNumber(service.price_from_cents, 0) / 100).toLocaleString("es-AR")}</small>
</div>
<b>&gt;</b>
</Link>
))}
</div>
</section>
<section id="opiniones" className="maker-public-section">
<div className="maker-public-section-head">
<h2>Opinion destacada</h2>
<Link href="#opiniones">Ver todas</Link>
</div>
{!allowVerifiedReviews ? (
<div className="empty-state">Este maker pauso temporalmente las nuevas reseñas verificadas.</div>
) : null}
{!showPastReviews ? (
<div className="empty-state">Este maker mantiene privadas sus resenas anteriores.</div>
) : null}
{showPastReviews ? visibleReviews.slice(0, 2).map((review) => (
<article key={String(review.id)} className="maker-public-review">
<div>
<strong>Trabajo verificado</strong>
<span>{toNumber(review.rating_overall, 5).toFixed(1)} / 5</span>
</div>
<p>{toText(review.comment, "Excelente comunicacion y calidad. Cumplio con el plazo y el resultado fue perfecto.")}</p>
</article>
)) : null}
{showPastReviews && visibleReviews.length === 0 ? <div className="empty-state">Todavia no hay opiniones publicadas.</div> : null}
</section>
<section id="informacion" className="maker-public-section maker-public-info-bottom">
<h2>Informacion del maker</h2>
<p>{toText(maker.description, "Especialista en piezas funcionales, prototipos y trabajos con contexto real.")}</p>
{socialLinks.length ? (
<div className="maker-public-social-links">
{socialLinks.map(([label, href]) => {
const safeHref = href.startsWith("http") ? href : `https://${href}`;
return (
<a key={label} href={safeHref} target="_blank" rel="noreferrer">
{label}
</a>
);
})}
</div>
) : null}
</section>
</section>
<div className="maker-public-bottom-cta">
<a href={contactHref} className="button button-primary">Escribir al maker</a>
</div>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Makers3D MVP",
short_name: "Makers3D",
description: "Marketplace MVP para descubrir y contactar makers 3D.",
start_url: "/",
display: "standalone",
background_color: "#fff7ed",
theme_color: "#f97316",
lang: "es",
icons: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any"
}
]
};
}
+9
View File
@@ -0,0 +1,9 @@
import MessagesClient from "../components/MessagesClient";
export default function MessagesPage() {
return (
<main className="page-shell messages-page">
<MessagesClient />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { MapHomeScreen } from "./components/MapHomeScreen";
export default async function HomePage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell map-home-page">
<MapHomeScreen searchParams={params} />
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import ProfileClient from "../components/ProfileClient";
export default function ProfilePage() {
return (
<main className="page-shell profile-account-page">
<ProfileClient />
</main>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import RegisterForm from "../components/RegisterForm";
export default async function RegisterPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const redirectTo = typeof params?.returnTo === "string" ? params.returnTo : "/account";
return (
<main className="page-shell">
<div className="auth-wrap">
<section className="auth-card">
<span className="eyebrow">Registro</span>
<h1 className="section-title">Crear cuenta unica</h1>
<p className="muted">La misma cuenta sirve para actuar como cliente, seguir consultas y abrir tu espacio maker.</p>
<RegisterForm redirectTo={redirectTo} />
<p className="muted">Si ya tienes acceso, <Link href={`/login?returnTo=${encodeURIComponent(redirectTo)}`}>entra aqui</Link>.</p>
</section>
</div>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DiscoverResultsScreen } from "../components/DiscoverResultsScreen";
export default async function ResultsPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
return (
<main className="page-shell discover-page">
<DiscoverResultsScreen searchParams={params} />
</main>
);
}
+99
View File
@@ -0,0 +1,99 @@
import Link from "next/link";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function toBool(value: unknown) {
return value === true || value === "true";
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
export default async function ServicePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { service } = await serverFetch<{ service: Record<string, unknown> }>(`/services/${slug}`);
return (
<main className="page-shell service-page">
<div className="detail-grid">
<section className="service-content">
<section className="panel stack">
<span className="eyebrow">Servicio publico</span>
<h1 className="headline" style={{ fontSize: "clamp(2rem, 5vw, 3.1rem)" }}>{toText(service.title)}</h1>
<p className="lead">{toText(service.description)}</p>
<div className="badges">
<span className="badge">{toText(service.category)}</span>
<span className="badge">{toBool(service.local_pickup) ? "Retiro local" : "Solo envio"}</span>
<span className="badge">{toBool(service.nationwide_shipping) ? "Envio nacional" : "Cobertura puntual"}</span>
</div>
<div className="meta-grid">
<div className="stat-tile"><span className="stat-value">{String(service.lead_time_days || 4)} dias</span><span className="mini-note">Plazo orientativo</span></div>
<div className="stat-tile"><span className="stat-value">Desde</span><span className="mini-note">${Math.round(Number(service.price_from_cents || 0) / 100).toLocaleString("es-AR")}</span></div>
<div className="stat-tile"><span className="stat-value">Visible</span><span className="mini-note">En mapa y perfil</span></div>
<div className="stat-tile"><span className="stat-value">Guiado</span><span className="mini-note">Contacto contextual</span></div>
</div>
</section>
<section className="panel stack">
<div className="section-head">
<div className="stack" style={{ gap: 6 }}>
<span className="eyebrow">Materiales y capacidades</span>
<h2 className="section-title">Informacion pensada para decidir rapido</h2>
</div>
</div>
<div className="badges">
{asArray(service.materials).map((item) => (
<span key={item} className="badge">{item}</span>
))}
{asArray(service.technologies).map((item) => (
<span key={item} className="badge">{item}</span>
))}
</div>
<div className="grid-cards">
<article className="visual-card">
<img className="thumb" src="/demo/work-dashboard-bracket.svg" alt="Uso" />
<div className="visual-body">
<strong>Uso principal</strong>
<span className="mini-note">Piezas funcionales, prototipos y necesidades reales con restricciones claras.</span>
</div>
</article>
<article className="visual-card">
<img className="thumb" src="/demo/work-custom.svg" alt="Entrega" />
<div className="visual-body">
<strong>Entrega</strong>
<span className="mini-note">Retiro local, envio o cobertura segun la configuracion del maker.</span>
</div>
</article>
<article className="visual-card">
<img className="thumb" src="/demo/work-coffee-hinge.svg" alt="Contexto" />
<div className="visual-body">
<strong>Consulta guiada</strong>
<span className="mini-note">El cliente no llega en frio. Llega con referencia, urgencia y uso final.</span>
</div>
</article>
</div>
</section>
</section>
<aside className="stack">
<section className="panel contact-card stack">
<span className="eyebrow">Iniciar consulta</span>
<h2 className="section-title">Pide este servicio con un flujo corto y util</h2>
<Link
href={`/consultas/nueva?makerId=${encodeURIComponent(String(service.maker_id))}&sourceType=service&sourceId=${encodeURIComponent(String(service.id))}&makerName=${encodeURIComponent(toText(service.maker_name, "Maker"))}&contextTitle=${encodeURIComponent(toText(service.title))}&returnTo=${encodeURIComponent(`/services/${slug}`)}`}
className="button button-primary"
>
Abrir contacto guiado
</Link>
<Link href={`/makers/${toText(service.maker_slug)}`} className="button button-secondary">Ver maker</Link>
</section>
</aside>
</div>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function asWorks(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value as Array<Record<string, unknown>> : [];
}
export default async function ShowcasePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { showcase } = await serverFetch<{ showcase: Record<string, unknown> }>(`/showcases/${slug}`);
const works = asWorks(showcase.works);
const makerName = toText(showcase.maker_name, "Maker");
const title = toText(showcase.title, "Escaparate");
const coverImage = toText(showcase.cover_image_url, toText(works[0]?.image_url, "/demo/work-custom.svg"));
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(showcase.maker_id))}&sourceType=showcase&sourceId=${encodeURIComponent(String(showcase.id))}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(title)}&returnTo=${encodeURIComponent(`/showcases/${slug}`)}`;
return (
<main className="page-shell showcase-public-page">
<section className="showcase-public-phone">
<section className="showcase-public-hero" style={{ backgroundImage: `url(${coverImage})` }}>
<div className="maker-public-top-actions">
<Link href={`/makers/${toText(showcase.maker_slug)}`} className="maker-public-circle-action" aria-label="Volver al maker">&lt;</Link>
<button className="maker-public-circle-action" type="button" aria-label="Compartir">sh</button>
</div>
<div>
<span className="eyebrow">Escaparate publico</span>
<h1>{title}</h1>
<p>{toText(showcase.description, "Coleccion de trabajos destacados del maker.")}</p>
</div>
</section>
<section className="showcase-public-stats">
<article><strong>{works.length}</strong><span>Trabajos</span></article>
<article><strong>1.284</strong><span>Visitas</span></article>
<article><strong>38</strong><span>Consultas</span></article>
<article><strong>4,9</strong><span>Valoracion</span></article>
</section>
{works[0] ? (
<section className="maker-public-section">
<span className="eyebrow">Trabajo destacado</span>
<Link href={`/works/${toText(works[0].slug)}`} className="showcase-featured-work">
<img src={toText(works[0].image_url, "/demo/work-custom.svg")} alt={toText(works[0].title)} />
<div>
<strong>{toText(works[0].title)}</strong>
<span>{toText(works[0].technology, "FDM")} | {toText(works[0].material, "PLA")}</span>
</div>
<b>&gt;</b>
</Link>
</section>
) : null}
<section className="maker-public-section">
<div className="maker-public-section-head">
<h2>Todos los trabajos</h2>
<span>{works.length}</span>
</div>
<div className="showcase-public-work-grid">
{works.map((work) => (
<Link key={String(work.id)} href={`/works/${toText(work.slug)}`}>
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
<strong>{toText(work.title)}</strong>
<span>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</span>
</Link>
))}
</div>
</section>
<section className="maker-public-section maker-public-info-bottom">
<span className="eyebrow">Compartir escaparate</span>
<div className="work-detail-share-actions">
<a className="work-detail-share-option" href={`https://wa.me/?text=${encodeURIComponent(`Mira este escaparate de ${makerName}: ${title} - https://dev.jazari.com.ar/showcases/${slug}`)}`}>WhatsApp</a>
<button className="work-detail-share-option" type="button">Copiar enlace</button>
</div>
</section>
</section>
<div className="maker-public-bottom-cta">
<a href={contactHref} className="button button-primary">Iniciar consulta desde este escaparate</a>
</div>
</main>
);
}
+170
View File
@@ -0,0 +1,170 @@
import Link from "next/link";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import FavoriteToggle from "../../components/FavoriteToggle";
import WorkHeroGallery from "../../components/WorkHeroGallery";
import WorkShareActions from "../../components/WorkShareActions";
import { serverFetch } from "../../lib/api";
function toText(value: unknown, fallback = "") {
return typeof value === "string" && value ? value : fallback;
}
function asArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function asRecords(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
}
function galleryFor(work: Record<string, unknown>) {
const images = [toText(work.image_url), ...asArray(work.gallery_urls)].filter(Boolean);
return Array.from(new Set(images));
}
function draftDataFor(work: Record<string, unknown>) {
return work.draft_data && typeof work.draft_data === "object" && !Array.isArray(work.draft_data)
? work.draft_data as Record<string, unknown>
: {};
}
export default async function WorkPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let work: Record<string, unknown>;
try {
const data = await serverFetch<{ work: Record<string, unknown> }>(`/works/${slug}`);
work = data.work;
} catch {
notFound();
}
const requestHeaders = await headers();
const gallery = galleryFor(work);
const draftData = draftDataFor(work);
const beforeImage = toText(draftData.beforeImageUrl);
const afterImage = toText(draftData.afterImageUrl);
const showBeforeAfter = Boolean(draftData.includeBeforeAfter && beforeImage && afterImage);
const relatedWorks = asRecords(work.related_works);
const title = toText(work.title, "Trabajo publicado");
const makerName = toText(work.maker_name, "Maker");
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host") || "dev.jazari.com.ar";
const protocol = requestHeaders.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https");
const workUrl = `${protocol}://${host}/works/${slug}`;
const contactHref = `/consultas/nueva?makerId=${encodeURIComponent(String(work.maker_id))}&sourceType=work&sourceId=${encodeURIComponent(String(work.id))}&makerName=${encodeURIComponent(makerName)}&contextTitle=${encodeURIComponent(title)}&returnTo=${encodeURIComponent(`/works/${slug}`)}`;
return (
<main className="page-shell work-detail-page">
<section className="work-detail-phone">
<WorkHeroGallery gallery={gallery} title={title} workId={String(work.id)} shareUrl={workUrl} />
<section className="work-detail-title-card">
<h1>{title}</h1>
<div className="work-detail-tags">
<span>{toText(work.technology, "FDM")}</span>
<span>{toText(work.material, "PLA")}</span>
</div>
<Link href={`/makers/${toText(work.maker_slug)}`} className="work-detail-maker-card">
<span>{makerName.slice(0, 2).toUpperCase()}</span>
<div>
<strong>{makerName}</strong>
<small><span className="map-home-star">*</span> 4,9 | 128 opiniones | Aceptando trabajos</small>
</div>
<b>&gt;</b>
</Link>
</section>
<section className="work-detail-section">
<h2>Que se hizo?</h2>
<p>{toText(work.summary, "Se reprodujo una pieza a medida para resolver una necesidad real del cliente.")}</p>
<div className="work-detail-feature-grid">
<article><span>01</span><strong>Pieza funcional</strong></article>
<article><span>02</span><strong>Resiste calor</strong></article>
<article><span>03</span><strong>1 unidad</strong></article>
<article><span>04</span><strong>48 h fabricacion</strong></article>
</div>
</section>
{showBeforeAfter ? (
<section className="work-detail-section">
<div className="work-detail-section-head">
<h2>Antes / Resultado</h2>
<Link href="#galeria">Ver mas</Link>
</div>
<div className="work-detail-before-after">
<article>
<img src={beforeImage} alt="Antes" />
<span>Antes</span>
</article>
<b>&gt;</b>
<article>
<img src={afterImage} alt="Resultado" />
<span>Resultado</span>
</article>
</div>
</section>
) : null}
<section className="work-detail-section">
<h2>Historia del proyecto</h2>
<p>{toText(work.story, "El cliente necesitaba recuperar una pieza o validar una solucion. Se tomo una referencia, se ajusto el diseno y se fabrico una version funcional lista para probar.")}</p>
</section>
<section className="work-detail-section">
<details open>
<summary>Detalles tecnicos</summary>
<div className="work-detail-specs">
<span>Tecnologia</span><strong>{toText(work.technology, "FDM")}</strong>
<span>Material</span><strong>{toText(work.material, "PETG")}</strong>
<span>Acabado</span><strong>Lijado y ajuste manual</strong>
<span>Resolucion</span><strong>0,16 mm capa</strong>
<span>Relleno</span><strong>40%</strong>
<span>Tiempo</span><strong>48 horas</strong>
</div>
</details>
</section>
<section id="galeria" className="work-detail-section">
<div className="work-detail-section-head">
<h2>Galeria</h2>
<span>{gallery.length} fotos</span>
</div>
<div className="work-detail-gallery-grid">
{gallery.map((image, index) => (
<img key={`${image}-${index}`} src={image} alt={`${title} ${index + 1}`} />
))}
</div>
</section>
{relatedWorks.length ? (
<section className="work-detail-section">
<div className="work-detail-section-head">
<h2>Tambien puede interesarte</h2>
<Link href="/discover">Ver todos</Link>
</div>
<div className="work-detail-related-rail">
{relatedWorks.map((item) => (
<Link key={String(item.id)} href={`/works/${toText(item.slug)}`}>
<img src={toText(item.image_url, "/demo/work-custom.svg")} alt={toText(item.title, "Trabajo sugerido")} />
<strong>{toText(item.title, "Trabajo sugerido")}</strong>
<span>{toText(item.technology, "FDM")} | {toText(item.material, "PETG")}</span>
</Link>
))}
</div>
</section>
) : null}
<section className="work-detail-section work-detail-share">
<h2>Compartir este trabajo</h2>
<p>Comparte este enlace para mostrar la referencia o enviarla por WhatsApp.</p>
<WorkShareActions title={title} url={workUrl} />
</section>
</section>
<div className="work-detail-bottom-cta">
<FavoriteToggle targetType="work" targetId={String(work.id)} compact />
<a href={contactHref} className="button button-primary">Quiero algo similar</a>
</div>
</main>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typedRoutes: true
};
export default nextConfig;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@makers3d/web",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"start": "next start",
"dev": "next dev",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"leaflet": "1.9.4",
"next": "16.2.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-leaflet": "5.0.0"
},
"devDependencies": {
"@types/leaflet": "1.9.21"
}
}
+608
View File
@@ -0,0 +1,608 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
leaflet:
specifier: 1.9.4
version: 1.9.4
next:
specifier: 16.2.10
version: 16.2.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react:
specifier: 19.2.0
version: 19.2.0
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
react-leaflet:
specifier: 5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
devDependencies:
'@types/leaflet':
specifier: 1.9.21
version: 1.9.21
packages:
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@next/env@16.2.10':
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
'@next/swc-darwin-arm64@16.2.10':
resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.2.10':
resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.10':
resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.10':
resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.2.10':
resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.2.10':
resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.10':
resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.10':
resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
baseline-browser-mapping@2.11.0:
resolution: {integrity: sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==}
engines: {node: '>=6.0.0'}
hasBin: true
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@16.2.10:
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
react: ^19.2.0
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react@19.2.0:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
babel-plugin-macros:
optional: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
snapshots:
'@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
optional: true
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@next/env@16.2.10': {}
'@next/swc-darwin-arm64@16.2.10':
optional: true
'@next/swc-darwin-x64@16.2.10':
optional: true
'@next/swc-linux-arm64-gnu@16.2.10':
optional: true
'@next/swc-linux-arm64-musl@16.2.10':
optional: true
'@next/swc-linux-x64-gnu@16.2.10':
optional: true
'@next/swc-linux-x64-musl@16.2.10':
optional: true
'@next/swc-win32-arm64-msvc@16.2.10':
optional: true
'@next/swc-win32-x64-msvc@16.2.10':
optional: true
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
leaflet: 1.9.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
baseline-browser-mapping@2.11.0: {}
caniuse-lite@1.0.30001806: {}
client-only@0.0.1: {}
detect-libc@2.1.2:
optional: true
leaflet@1.9.4: {}
nanoid@3.3.16: {}
next@16.2.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.2.10
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.11.0
caniuse-lite: 1.0.30001806
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.10
'@next/swc-darwin-x64': 16.2.10
'@next/swc-linux-arm64-gnu': 16.2.10
'@next/swc-linux-arm64-musl': 16.2.10
'@next/swc-linux-x64-gnu': 16.2.10
'@next/swc-linux-x64-musl': 16.2.10
'@next/swc-win32-arm64-msvc': 16.2.10
'@next/swc-win32-x64-msvc': 16.2.10
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
picocolors@1.1.1: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
scheduler: 0.27.0
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
leaflet: 1.9.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react@19.2.0: {}
scheduler@0.27.0: {}
semver@7.8.5:
optional: true
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
source-map-js@1.2.1: {}
styled-jsx@5.1.6(react@19.2.0):
dependencies:
client-only: 0.0.1
react: 19.2.0
tslib@2.8.1: {}
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
sharp: set this to true or false
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#17110b"/>
<stop offset="1" stop-color="#342313"/>
</linearGradient>
<linearGradient id="filament" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#ffd37c"/>
<stop offset="1" stop-color="#b96d24"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<path d="M160 610h880" stroke="#604321" stroke-width="10" opacity=".55"/>
<g transform="translate(602 386)">
<circle r="196" fill="url(#filament)"/>
<circle r="96" fill="#17110b"/>
<circle r="54" fill="#e9a747"/>
<g fill="#ffd37c">
<path d="M-24-294h48l22 122h-92z"/>
<path d="M-24 172h48l22 122h-92z"/>
<path d="M-294-24v48l122 22v-92z"/>
<path d="M172-24v48l122 22v-92z"/>
<path d="M-224-224 -190-258 -88-187 -153-122z"/>
<path d="M190-258 224-224 153-122 88-187z"/>
<path d="M-224 224 -190 258 -88 187 -153 122z"/>
<path d="M190 258 224 224 153 122 88 187z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
<stop offset="0" stop-color="#2a3046"/>
<stop offset="1" stop-color="#060912"/>
</radialGradient>
<linearGradient id="stone" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#d6d8df"/>
<stop offset="1" stop-color="#636b80"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="600" cy="640" rx="360" ry="54" fill="#01040b" opacity=".58"/>
<g fill="url(#stone)">
<path d="M468 598h264l-28-88H496z"/>
<path d="M528 506h144l34-166H494z"/>
<circle cx="600" cy="278" r="82"/>
<path d="M510 356c-86 36-126 88-122 156h96c4-50 30-82 78-102z"/>
<path d="M690 356c86 36 126 88 122 156h-96c-4-50-30-82-78-102z"/>
</g>
<circle cx="570" cy="272" r="14" fill="#060912"/>
<circle cx="630" cy="272" r="14" fill="#060912"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#06101f"/>
<stop offset="1" stop-color="#16345c"/>
</linearGradient>
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#ecf8ff"/>
<stop offset="1" stop-color="#7ea7c8"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="604" cy="624" rx="390" ry="60" fill="#020713" opacity=".6"/>
<rect x="328" y="270" width="544" height="300" rx="54" fill="url(#part)"/>
<rect x="406" y="354" width="130" height="92" rx="18" fill="#06101f" opacity=".7"/>
<rect x="596" y="330" width="192" height="142" rx="28" fill="#06101f" opacity=".64"/>
<path d="M328 515h544v55H328z" fill="#bcd4e8"/>
<circle cx="350" cy="292" r="42" fill="#51d4ff" opacity=".75"/>
<circle cx="850" cy="548" r="44" fill="#2b82ff" opacity=".65"/>
</svg>

After

Width:  |  Height:  |  Size: 995 B

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<radialGradient id="bg" cx="50%" cy="35%" r="68%">
<stop offset="0" stop-color="#3d155d"/>
<stop offset="1" stop-color="#070812"/>
</radialGradient>
<linearGradient id="resin" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#ff5ea8"/>
<stop offset=".6" stop-color="#8f45ff"/>
<stop offset="1" stop-color="#2fb6ff"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="600" cy="625" rx="320" ry="54" fill="#01040b" opacity=".6"/>
<path d="M428 590 326 428l86-182h226l122 124 116 32-52 138-148-28-88 78Z" fill="url(#resin)"/>
<circle cx="486" cy="392" r="42" fill="#090b18" opacity=".48"/>
<circle cx="670" cy="424" r="58" fill="#090b18" opacity=".38"/>
<path d="M414 246c78 44 160 44 244 0" fill="none" stroke="#ffd6ef" stroke-width="20" opacity=".5"/>
</svg>

After

Width:  |  Height:  |  Size: 939 B

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#09151d"/>
<stop offset="1" stop-color="#10382d"/>
</linearGradient>
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#7df7bd"/>
<stop offset="1" stop-color="#0e9d78"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="600" cy="628" rx="380" ry="58" fill="#020713" opacity=".58"/>
<path d="M334 534V316l148-86h236l148 86v218l-148 86H482z" fill="url(#part)"/>
<path d="M464 366h272v116H464z" fill="#06101f" opacity=".58"/>
<circle cx="438" cy="318" r="38" fill="#06101f" opacity=".62"/>
<circle cx="762" cy="318" r="38" fill="#06101f" opacity=".62"/>
<circle cx="438" cy="532" r="38" fill="#06101f" opacity=".62"/>
<circle cx="762" cy="532" r="38" fill="#06101f" opacity=".62"/>
</svg>

After

Width:  |  Height:  |  Size: 972 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#1d4ed8" />
<stop offset="1" stop-color="#06b6d4" />
</linearGradient>
</defs>
<rect width="1200" height="800" fill="#081220" />
<circle cx="900" cy="160" r="220" fill="rgba(45,168,255,0.2)" />
<circle cx="220" cy="640" r="240" fill="rgba(124,228,255,0.14)" />
<path d="M260 560l180-300h320l180 300-180 120H440z" fill="url(#g)" opacity="0.95" />
<path d="M420 300h360v120H420z" fill="#07101b" opacity="0.65" />
<text x="90" y="120" fill="#f8fafc" font-size="72" font-family="Arial, sans-serif" font-weight="700">MakerLab BA</text>
<text x="90" y="190" fill="#cbd5e1" font-size="32" font-family="Arial, sans-serif">Repuestos funcionales y prototipos</text>
</svg>

After

Width:  |  Height:  |  Size: 854 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="g2" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#7c3aed" />
<stop offset="1" stop-color="#2dd4bf" />
</linearGradient>
</defs>
<rect width="1200" height="800" fill="#0c182a" />
<path d="M210 600l210-360h360l210 360-210 120H420z" fill="url(#g2)" opacity="0.88" />
<text x="90" y="120" fill="#f8fafc" font-size="72" font-family="Arial, sans-serif" font-weight="700">Tu Maker</text>
<text x="90" y="185" fill="#cbd5e1" font-size="32" font-family="Arial, sans-serif">Usa esta portada mientras preparas tu perfil</text>
</svg>

After

Width:  |  Height:  |  Size: 660 B

@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<radialGradient id="bg" cx="50%" cy="45%" r="62%">
<stop offset="0" stop-color="#243f5f"/>
<stop offset="1" stop-color="#07101d"/>
</radialGradient>
<linearGradient id="plastic" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#f7e7bf"/>
<stop offset="1" stop-color="#b38b4e"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="610" cy="612" rx="370" ry="64" fill="#020713" opacity=".55"/>
<g transform="translate(600 382)">
<circle r="194" fill="url(#plastic)"/>
<circle r="82" fill="#07101d"/>
<circle r="48" fill="#d7b875"/>
<g fill="#f7e7bf">
<rect x="-26" y="-276" width="52" height="102" rx="12"/>
<rect x="-26" y="174" width="52" height="102" rx="12"/>
<rect x="-276" y="-26" width="102" height="52" rx="12"/>
<rect x="174" y="-26" width="102" height="52" rx="12"/>
<rect x="-214" y="-214" width="92" height="52" rx="12" transform="rotate(45)"/>
<rect x="122" y="-214" width="92" height="52" rx="12" transform="rotate(45)"/>
<rect x="-214" y="162" width="92" height="52" rx="12" transform="rotate(45)"/>
<rect x="122" y="162" width="92" height="52" rx="12" transform="rotate(45)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#061322"/>
<stop offset="1" stop-color="#0d2e4f"/>
</linearGradient>
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#78e6ff"/>
<stop offset=".55" stop-color="#219cff"/>
<stop offset="1" stop-color="#0e4fb3"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<path d="M284 554 412 254h358l148 210-152 148H408Z" fill="url(#part)"/>
<path d="M380 598h410l-42 48H420Z" fill="#7ce4ff" opacity=".75"/>
<circle cx="438" cy="416" r="58" fill="#082033" opacity=".64"/>
<circle cx="690" cy="412" r="76" fill="#082033" opacity=".64"/>
<ellipse cx="610" cy="646" rx="360" ry="52" fill="#020713" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 885 B

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
<defs>
<radialGradient id="bg" cx="50%" cy="38%" r="70%">
<stop offset="0" stop-color="#1c2941"/>
<stop offset="1" stop-color="#050b14"/>
</radialGradient>
<linearGradient id="part" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#d9e2ea"/>
<stop offset="1" stop-color="#5b6877"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<ellipse cx="600" cy="620" rx="360" ry="58" fill="#020713" opacity=".55"/>
<path d="M310 508h580l-82 94H388Z" fill="#7c8795"/>
<path d="M374 250h452a64 64 0 0 1 64 64v194H310V314a64 64 0 0 1 64-64Z" fill="url(#part)"/>
<rect x="410" y="330" width="156" height="82" rx="18" fill="#07101d" opacity=".72"/>
<rect x="634" y="330" width="156" height="82" rx="18" fill="#07101d" opacity=".72"/>
<circle cx="420" cy="512" r="32" fill="#0a1220"/>
<circle cx="780" cy="512" r="32" fill="#0a1220"/>
</svg>

After

Width:  |  Height:  |  Size: 987 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title>Makers3D</title>
<desc>Icono de la app Makers3D.</desc>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f97316" />
<stop offset="100%" stop-color="#ea580c" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)" />
<path d="M70 180V76h22l36 48 36-48h22v104h-24v-64l-30 39h-8l-30-39v64z" fill="#fff7ed" />
</svg>

After

Width:  |  Height:  |  Size: 533 B

+11
View File
@@ -0,0 +1,11 @@
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", () => {
// Minimal service worker to enable installability on Android without adding complex caching logic.
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"noEmit": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json tsconfig.base.json ./
COPY apps/worker/package.json apps/worker/package.json
RUN npm install
COPY . .
RUN npm run build -w @makers3d/worker
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/worker/dist ./apps/worker/dist
ENV NODE_ENV=production
CMD ["node", "apps/worker/dist/index.js"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@makers3d/worker",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/pg": "^8.15.6"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Pool } from "pg";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
const pool = new Pool({ connectionString: databaseUrl });
async function processJobs(): Promise<void> {
const client = await pool.connect();
try {
await client.query("begin");
const result = await client.query<{
id: string;
type: string;
}>(
`
select id, type
from jobs
where status = 'pending'
order by created_at asc
limit 5
for update skip locked
`
);
for (const job of result.rows) {
await client.query(`update jobs set status = 'processing', attempts = attempts + 1, updated_at = now() where id = $1`, [job.id]);
await client.query(`update jobs set status = 'done', updated_at = now() where id = $1`, [job.id]);
console.log(`Processed job ${job.id} (${job.type})`);
}
await client.query("commit");
} catch (error) {
await client.query("rollback");
console.error("Worker cycle failed", error);
} finally {
client.release();
}
}
async function loop() {
while (true) {
await processJobs();
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
loop().catch((error) => {
console.error(error);
process.exit(1);
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src/**/*.ts"]
}