@pylonsync/create-pylon 0.3.274 → 0.3.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +284 -0
- package/templates/agency/components/marketing.tsx +187 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +727 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +357 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { Suspense, use } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Link,
|
|
4
|
+
type GenerateMetadata,
|
|
5
|
+
type Metadata,
|
|
6
|
+
type PageProps,
|
|
7
|
+
type ServerData,
|
|
8
|
+
type SsrResponse,
|
|
9
|
+
} from "@pylonsync/react";
|
|
10
|
+
import { Badge } from "../../../ui/badge";
|
|
11
|
+
import { OfferPanel } from "../../../client/OfferPanel";
|
|
12
|
+
import { CategoryIcon } from "../../_components/CategoryIcon";
|
|
13
|
+
import { WatchButton } from "../../../client/WatchButton";
|
|
14
|
+
import {
|
|
15
|
+
gradient,
|
|
16
|
+
money,
|
|
17
|
+
conditionLabel,
|
|
18
|
+
type Listing,
|
|
19
|
+
} from "../../../client/market";
|
|
20
|
+
|
|
21
|
+
// Data-driven SEO: the title + description come from the listing itself,
|
|
22
|
+
// fetched on the server. `generateMetadata` is handed the same PageProps as
|
|
23
|
+
// the page (params + serverData), so it reads the row directly.
|
|
24
|
+
// Resolve a listing from the URL segment, which is its slug
|
|
25
|
+
// ("herman-miller-aeron-a1f3"). Falls back to a raw id lookup so older
|
|
26
|
+
// id-shaped links keep working.
|
|
27
|
+
async function resolveListing(
|
|
28
|
+
serverData: ServerData,
|
|
29
|
+
key: string,
|
|
30
|
+
): Promise<Listing | null> {
|
|
31
|
+
return (
|
|
32
|
+
(await serverData.lookup<Listing>("Listing", "slug", key)) ??
|
|
33
|
+
(await serverData.get<Listing>("Listing", key))
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const generateMetadata: GenerateMetadata = async ({
|
|
38
|
+
params,
|
|
39
|
+
serverData,
|
|
40
|
+
}): Promise<Metadata> => {
|
|
41
|
+
const l = await resolveListing(serverData, params.id);
|
|
42
|
+
if (!l) return { title: "Listing not found · Pylon Market" };
|
|
43
|
+
return {
|
|
44
|
+
title: `${l.title} — ${money(l.price)} · Pylon Market`,
|
|
45
|
+
description:
|
|
46
|
+
l.description?.slice(0, 155) ||
|
|
47
|
+
`${l.title} for sale on Pylon Market (${conditionLabel(l.condition)}).`,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function Detail({
|
|
52
|
+
serverData,
|
|
53
|
+
response,
|
|
54
|
+
id,
|
|
55
|
+
}: {
|
|
56
|
+
serverData: ServerData;
|
|
57
|
+
response: SsrResponse;
|
|
58
|
+
id: string;
|
|
59
|
+
}) {
|
|
60
|
+
const listing = use(resolveListing(serverData, id));
|
|
61
|
+
|
|
62
|
+
if (!listing) {
|
|
63
|
+
response.setStatus(404);
|
|
64
|
+
return (
|
|
65
|
+
<div className="rounded-xl border border-dashed p-12 text-center">
|
|
66
|
+
<p className="font-medium">This listing is gone.</p>
|
|
67
|
+
<Link href="/" className="mt-2 inline-block text-sm underline">
|
|
68
|
+
Back to the market
|
|
69
|
+
</Link>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="space-y-6">
|
|
76
|
+
<Link
|
|
77
|
+
href="/"
|
|
78
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
79
|
+
>
|
|
80
|
+
← Back to the market
|
|
81
|
+
</Link>
|
|
82
|
+
|
|
83
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
84
|
+
<div
|
|
85
|
+
className="relative flex aspect-square items-center justify-center rounded-2xl text-white/90"
|
|
86
|
+
style={{ background: gradient(listing.seed || listing.id) }}
|
|
87
|
+
>
|
|
88
|
+
<CategoryIcon category={listing.category} className="size-28" />
|
|
89
|
+
<WatchButton
|
|
90
|
+
listingId={listing.id}
|
|
91
|
+
listingTitle={listing.title}
|
|
92
|
+
className="absolute right-3 top-3"
|
|
93
|
+
/>
|
|
94
|
+
{listing.status === "sold" ? (
|
|
95
|
+
<span className="absolute inset-0 grid place-items-center rounded-2xl bg-black/55 text-2xl font-bold uppercase tracking-wide">
|
|
96
|
+
Sold
|
|
97
|
+
</span>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex flex-col gap-4">
|
|
102
|
+
<div className="space-y-1">
|
|
103
|
+
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
|
104
|
+
<span>{listing.category}</span>
|
|
105
|
+
<span>·</span>
|
|
106
|
+
<Badge variant="outline" className="text-[10px]">
|
|
107
|
+
{conditionLabel(listing.condition)}
|
|
108
|
+
</Badge>
|
|
109
|
+
</div>
|
|
110
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
111
|
+
{listing.title}
|
|
112
|
+
</h1>
|
|
113
|
+
<p className="text-3xl font-semibold tabular-nums">{money(listing.price)}</p>
|
|
114
|
+
<p className="text-sm text-muted-foreground">
|
|
115
|
+
Listed by <span className="font-medium">{listing.sellerName}</span>
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{listing.description ? (
|
|
120
|
+
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
|
121
|
+
{listing.description}
|
|
122
|
+
</p>
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
{/* Realtime client island: live offers, accept/decline. */}
|
|
126
|
+
<div className="mt-2">
|
|
127
|
+
<OfferPanel
|
|
128
|
+
listingId={listing.id}
|
|
129
|
+
sellerId={listing.sellerId}
|
|
130
|
+
sellerName={listing.sellerName}
|
|
131
|
+
title={listing.title}
|
|
132
|
+
price={listing.price}
|
|
133
|
+
status={listing.status}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default function ListingPage({
|
|
143
|
+
params,
|
|
144
|
+
serverData,
|
|
145
|
+
response,
|
|
146
|
+
}: PageProps) {
|
|
147
|
+
return (
|
|
148
|
+
<Suspense
|
|
149
|
+
fallback={
|
|
150
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
151
|
+
<div className="aspect-square animate-pulse rounded-2xl bg-muted" />
|
|
152
|
+
<div className="space-y-3">
|
|
153
|
+
<div className="h-6 w-2/3 animate-pulse rounded bg-muted" />
|
|
154
|
+
<div className="h-8 w-1/3 animate-pulse rounded bg-muted" />
|
|
155
|
+
<div className="h-24 animate-pulse rounded bg-muted" />
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
<Detail serverData={serverData} response={response} id={params.id} />
|
|
161
|
+
</Suspense>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { MyMarket } from "../../client/MyMarket";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "My Market · Pylon Market",
|
|
7
|
+
description: "Your listings and offers.",
|
|
8
|
+
robots: "noindex", // personal dashboard — keep it out of search
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Fully interactive dashboard (three live queries scoped to you), so the
|
|
12
|
+
// whole page is the client island.
|
|
13
|
+
export default function MePage() {
|
|
14
|
+
return <MyMarket />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when a
|
|
5
|
+
// page calls `response.notFound()` — e.g. a listing slug that doesn't resolve).
|
|
6
|
+
// Hydrated, so the link is a client nav.
|
|
7
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
10
|
+
<h1 className="text-3xl font-semibold tracking-tight">404</h1>
|
|
11
|
+
<p className="mt-2 text-muted-foreground">We couldn't find that listing.</p>
|
|
12
|
+
<Link
|
|
13
|
+
href="/"
|
|
14
|
+
className="mt-6 inline-flex h-10 items-center rounded-md bg-foreground px-5 text-sm font-medium text-background transition hover:opacity-90"
|
|
15
|
+
>
|
|
16
|
+
Back to browse
|
|
17
|
+
</Link>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import React, { Suspense, use } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Link,
|
|
4
|
+
type Metadata,
|
|
5
|
+
type PageProps,
|
|
6
|
+
type ServerData,
|
|
7
|
+
} from "@pylonsync/react";
|
|
8
|
+
import { Card } from "../ui/card";
|
|
9
|
+
import { Badge } from "../ui/badge";
|
|
10
|
+
import { LiveTicker } from "../client/LiveTicker";
|
|
11
|
+
import { SeedOnEmpty } from "../client/SeedOnEmpty";
|
|
12
|
+
import { CategoryIcon } from "./_components/CategoryIcon";
|
|
13
|
+
import { WatchButton } from "../client/WatchButton";
|
|
14
|
+
import { gradient, money, conditionLabel, type Listing } from "../client/market";
|
|
15
|
+
|
|
16
|
+
export const metadata: Metadata = {
|
|
17
|
+
title: "Pylon Market — buy & sell locally, live",
|
|
18
|
+
description:
|
|
19
|
+
"A live local marketplace. Server-rendered listings for SEO, realtime offers over the sync engine — one Pylon binary, one port.",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const CATEGORIES = [
|
|
23
|
+
"all", "furniture", "electronics", "cameras", "bikes", "audio", "kitchen",
|
|
24
|
+
"instruments", "outdoor", "apparel",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// The grid suspends on the server-side read and streams in with real rows in
|
|
28
|
+
// the HTML (good for SEO + LCP). `serverData.query` runs through the same
|
|
29
|
+
// policy gate as a query function's ctx.db.
|
|
30
|
+
function Grid({
|
|
31
|
+
serverData,
|
|
32
|
+
category,
|
|
33
|
+
}: {
|
|
34
|
+
serverData: ServerData;
|
|
35
|
+
category: string;
|
|
36
|
+
}) {
|
|
37
|
+
const active = use(serverData.query<Listing>("Listing", { status: "active" }));
|
|
38
|
+
const sorted = [...active].sort((a, b) =>
|
|
39
|
+
b.createdAt.localeCompare(a.createdAt),
|
|
40
|
+
);
|
|
41
|
+
const listings =
|
|
42
|
+
category === "all"
|
|
43
|
+
? sorted
|
|
44
|
+
: sorted.filter((l) => l.category === category);
|
|
45
|
+
|
|
46
|
+
if (listings.length === 0) {
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<div className="rounded-xl border border-dashed p-12 text-center">
|
|
50
|
+
<p className="text-sm text-muted-foreground">
|
|
51
|
+
{active.length === 0
|
|
52
|
+
? "Setting up a few sample listings…"
|
|
53
|
+
: "Nothing in this category yet."}
|
|
54
|
+
</p>
|
|
55
|
+
{category !== "all" ? (
|
|
56
|
+
<Link href="/" className="mt-2 inline-block text-sm underline">
|
|
57
|
+
See everything
|
|
58
|
+
</Link>
|
|
59
|
+
) : null}
|
|
60
|
+
</div>
|
|
61
|
+
<SeedOnEmpty count={active.length} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
|
68
|
+
{listings.map((l) => (
|
|
69
|
+
<Link key={l.id} href={`/listing/${l.slug || l.id}`} className="group">
|
|
70
|
+
<Card className="flex flex-col overflow-hidden p-0 transition group-hover:-translate-y-0.5 group-hover:shadow-md">
|
|
71
|
+
<div
|
|
72
|
+
className="relative flex aspect-square items-center justify-center text-white/90"
|
|
73
|
+
style={{ background: gradient(l.seed || l.id) }}
|
|
74
|
+
>
|
|
75
|
+
<CategoryIcon category={l.category} className="size-14" />
|
|
76
|
+
<WatchButton
|
|
77
|
+
listingId={l.id}
|
|
78
|
+
listingTitle={l.title}
|
|
79
|
+
className="absolute right-2 top-2 size-8"
|
|
80
|
+
/>
|
|
81
|
+
{l.status === "sold" ? (
|
|
82
|
+
<span className="absolute inset-0 grid place-items-center bg-black/55 text-sm font-bold uppercase tracking-wide">
|
|
83
|
+
Sold
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex flex-1 flex-col gap-1 p-3">
|
|
88
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
89
|
+
{l.category}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="line-clamp-2 min-h-[34px] text-sm font-medium leading-snug">
|
|
92
|
+
{l.title}
|
|
93
|
+
</span>
|
|
94
|
+
<div className="mt-1 flex items-center justify-between">
|
|
95
|
+
<span className="font-semibold tabular-nums">{money(l.price)}</span>
|
|
96
|
+
<Badge variant="outline" className="text-[10px]">
|
|
97
|
+
{conditionLabel(l.condition)}
|
|
98
|
+
</Badge>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</Card>
|
|
102
|
+
</Link>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default function BrowsePage({ searchParams, serverData }: PageProps) {
|
|
109
|
+
const category =
|
|
110
|
+
typeof searchParams.category === "string" ? searchParams.category : "all";
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="space-y-6">
|
|
114
|
+
<section className="space-y-1">
|
|
115
|
+
<h1 className="text-3xl font-semibold tracking-tight">
|
|
116
|
+
The local marketplace that's actually live
|
|
117
|
+
</h1>
|
|
118
|
+
<p className="text-muted-foreground">
|
|
119
|
+
Listings are server-rendered for search engines; offers are realtime.
|
|
120
|
+
Open this in two tabs — list in one, watch it appear in the other.
|
|
121
|
+
</p>
|
|
122
|
+
</section>
|
|
123
|
+
|
|
124
|
+
{/* Realtime client island */}
|
|
125
|
+
<LiveTicker />
|
|
126
|
+
|
|
127
|
+
<nav className="flex flex-wrap gap-2">
|
|
128
|
+
{CATEGORIES.map((c) => (
|
|
129
|
+
<Link
|
|
130
|
+
key={c}
|
|
131
|
+
href={c === "all" ? "/" : `/?category=${c}`}
|
|
132
|
+
className={`rounded-full border px-3 py-1 text-sm transition hover:bg-muted ${
|
|
133
|
+
category === c
|
|
134
|
+
? "border-foreground bg-foreground text-background hover:bg-foreground"
|
|
135
|
+
: "text-muted-foreground"
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
{c}
|
|
139
|
+
</Link>
|
|
140
|
+
))}
|
|
141
|
+
</nav>
|
|
142
|
+
|
|
143
|
+
<Suspense
|
|
144
|
+
fallback={
|
|
145
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
|
146
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
147
|
+
<div
|
|
148
|
+
key={i}
|
|
149
|
+
className="aspect-[3/4] animate-pulse rounded-xl bg-muted"
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
}
|
|
154
|
+
>
|
|
155
|
+
<Grid serverData={serverData} category={category} />
|
|
156
|
+
</Suspense>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Robots } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/robots.ts → served at /robots.txt. Point SITE_URL at your domain in prod.
|
|
4
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
|
+
|
|
6
|
+
export default function robots(): Robots {
|
|
7
|
+
return {
|
|
8
|
+
// Browse + listings are indexable; keep the personal inbox and API out.
|
|
9
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/me", "/api/"] },
|
|
10
|
+
sitemap: `${SITE}/sitemap.xml`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { SellForm } from "../../client/SellForm";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Sell an item · Pylon Market",
|
|
7
|
+
description: "List something for sale in seconds — buyers' offers arrive live.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function SellPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="mx-auto max-w-xl space-y-6">
|
|
13
|
+
<header className="space-y-1">
|
|
14
|
+
<h1 className="text-2xl font-semibold tracking-tight">List an item</h1>
|
|
15
|
+
<p className="text-sm text-muted-foreground">
|
|
16
|
+
It goes live instantly. Offers land in your{" "}
|
|
17
|
+
<a href="/me" className="underline">
|
|
18
|
+
My Market
|
|
19
|
+
</a>{" "}
|
|
20
|
+
inbox in realtime.
|
|
21
|
+
</p>
|
|
22
|
+
</header>
|
|
23
|
+
<SellForm />
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
+
// production. We list the static surfaces here; for a real catalog, map over
|
|
5
|
+
// active listings (read them via the entity API) and add a `/listing/<slug>`
|
|
6
|
+
// entry per row.
|
|
7
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
8
|
+
|
|
9
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
10
|
+
return [
|
|
11
|
+
{ url: `${SITE}/`, changeFrequency: "hourly", priority: 1 },
|
|
12
|
+
{ url: `${SITE}/sell`, changeFrequency: "monthly", priority: 0.5 },
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pylon Market — a live local marketplace.
|
|
3
|
+
*
|
|
4
|
+
* Anyone (a guest session) can list an item for sale; anyone else can make
|
|
5
|
+
* an offer. Sellers watch offers arrive in realtime and accept/decline them.
|
|
6
|
+
*
|
|
7
|
+
* The Pylon story this demo tells:
|
|
8
|
+
* - The browse grid (`/`) and each listing page (`/listing/:id`) are
|
|
9
|
+
* SERVER-RENDERED with real rows from the database (good for SEO + LCP) —
|
|
10
|
+
* view source and the products are in the HTML, not fetched later.
|
|
11
|
+
* - The interactive, realtime bits — the "just listed" ticker, the live
|
|
12
|
+
* offers on a listing, your inbox on `/me` — ride the sync engine: a
|
|
13
|
+
* single `useQuery` fans every write out to every open tab instantly.
|
|
14
|
+
* - One binary, one port. SSR + REST + WebSockets all from `pylon dev`.
|
|
15
|
+
* No Next.js app, no separate realtime service.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
entity,
|
|
19
|
+
field,
|
|
20
|
+
policy,
|
|
21
|
+
buildManifest,
|
|
22
|
+
discoverAppRoutes,
|
|
23
|
+
} from "@pylonsync/sdk";
|
|
24
|
+
|
|
25
|
+
// Accounts. Email/password auth is built in: registering through
|
|
26
|
+
// /api/auth/password/register hashes the password and writes this row.
|
|
27
|
+
// `passwordHash` is server-only — never serialized to clients.
|
|
28
|
+
const User = entity(
|
|
29
|
+
"User",
|
|
30
|
+
{
|
|
31
|
+
email: field.string(),
|
|
32
|
+
displayName: field.string(),
|
|
33
|
+
// Set by the auth subsystem on register (a default avatar tint).
|
|
34
|
+
avatarColor: field.string().optional(),
|
|
35
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
36
|
+
createdAt: field.datetime().defaultNow(),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
indexes: [{ name: "by_email", fields: ["email"], unique: true }],
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// A thing for sale. `seed` drives a deterministic gradient "photo" so the
|
|
44
|
+
// demo needs no image hosting. `status` flips active → sold when an offer
|
|
45
|
+
// is accepted.
|
|
46
|
+
//
|
|
47
|
+
// `sellerId: field.owner()` is what lets SellForm create a listing with a
|
|
48
|
+
// plain, optimistic `db.insert` (it shows in the live ticker the instant
|
|
49
|
+
// you post — no server round-trip) while the seller id stays unspoofable:
|
|
50
|
+
// the framework stamps it from the session and rejects any forged value.
|
|
51
|
+
// No createListing function needed. `status` + `createdAt` default
|
|
52
|
+
// server-side so the client doesn't have to send them.
|
|
53
|
+
const Listing = entity(
|
|
54
|
+
"Listing",
|
|
55
|
+
{
|
|
56
|
+
sellerId: field.string().owner(),
|
|
57
|
+
sellerName: field.string(),
|
|
58
|
+
title: field.string(),
|
|
59
|
+
// Human-readable URL key: "herman-miller-aeron-size-b-a1f3". Unique so it
|
|
60
|
+
// addresses exactly one listing; the detail route resolves by it.
|
|
61
|
+
slug: field.string().unique(),
|
|
62
|
+
description: field.string(),
|
|
63
|
+
price: field.float(),
|
|
64
|
+
category: field.string(),
|
|
65
|
+
condition: field.string(), // new | like-new | good | fair
|
|
66
|
+
status: field.string().default("active"), // active | sold
|
|
67
|
+
seed: field.string(),
|
|
68
|
+
createdAt: field.datetime().defaultNow(),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
indexes: [
|
|
72
|
+
{ name: "by_status", fields: ["status"], unique: false },
|
|
73
|
+
{ name: "by_seller", fields: ["sellerId"], unique: false },
|
|
74
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
75
|
+
{ name: "by_slug", fields: ["slug"], unique: true },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// A buyer's bid on a listing. The seller responds; accepting marks the
|
|
81
|
+
// listing sold and auto-declines the rest.
|
|
82
|
+
const Offer = entity(
|
|
83
|
+
"Offer",
|
|
84
|
+
{
|
|
85
|
+
listingId: field.string(),
|
|
86
|
+
listingTitle: field.string(),
|
|
87
|
+
sellerId: field.string(),
|
|
88
|
+
// `buyerId: field.owner()` keeps the bidder unspoofable on the
|
|
89
|
+
// optimistic db.useMutation path too — even though makeOffer also
|
|
90
|
+
// stamps it server-side, the field-level guarantee is defense in
|
|
91
|
+
// depth (and documents intent).
|
|
92
|
+
buyerId: field.string().owner(),
|
|
93
|
+
buyerName: field.string(),
|
|
94
|
+
amount: field.float(),
|
|
95
|
+
message: field.string().optional(),
|
|
96
|
+
status: field.string().default("pending"), // pending | accepted | declined
|
|
97
|
+
createdAt: field.datetime().defaultNow(),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
indexes: [
|
|
101
|
+
{ name: "by_listing", fields: ["listingId"], unique: false },
|
|
102
|
+
{ name: "by_buyer", fields: ["buyerId"], unique: false },
|
|
103
|
+
{ name: "by_seller", fields: ["sellerId"], unique: false },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// A buyer's saved listing. Private to the watcher (owner-scoped read), so
|
|
109
|
+
// your watchlist is yours alone. `listingTitle` is denormalized so the
|
|
110
|
+
// "Watching" list renders without a join.
|
|
111
|
+
const Watch = entity(
|
|
112
|
+
"Watch",
|
|
113
|
+
{
|
|
114
|
+
userId: field.string().owner(),
|
|
115
|
+
listingId: field.string(),
|
|
116
|
+
listingTitle: field.string(),
|
|
117
|
+
createdAt: field.datetime().defaultNow(),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
indexes: [
|
|
121
|
+
{ name: "by_user", fields: ["userId"], unique: false },
|
|
122
|
+
// One watch per (user, listing) — toggling the heart inserts/deletes
|
|
123
|
+
// this row.
|
|
124
|
+
{ name: "by_user_listing", fields: ["userId", "listingId"], unique: true },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Public marketplace: everyone can read listings + offers (so buyers and
|
|
130
|
+
// sellers both see the live state). Writes require a session and are
|
|
131
|
+
// owner-scoped; the heavy lifting (accept = mark sold + decline siblings)
|
|
132
|
+
// runs in functions where it can enforce "only the seller responds".
|
|
133
|
+
// Signed-in users can read profiles (to render seller/buyer names). The
|
|
134
|
+
// auth subsystem owns writes — registration/login go through
|
|
135
|
+
// /api/auth/password/*, not the entity API, so direct inserts/updates are
|
|
136
|
+
// closed off.
|
|
137
|
+
const userPolicy = policy({
|
|
138
|
+
name: "user_access",
|
|
139
|
+
entity: "User",
|
|
140
|
+
allowRead: "auth.userId != null",
|
|
141
|
+
allowInsert: "false",
|
|
142
|
+
allowUpdate: "false",
|
|
143
|
+
allowDelete: "false",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Watchlists are private: you can only read, add to, or remove from your own.
|
|
147
|
+
const watchPolicy = policy({
|
|
148
|
+
name: "watch_access",
|
|
149
|
+
entity: "Watch",
|
|
150
|
+
allowRead: "auth.userId == data.userId",
|
|
151
|
+
allowInsert: "auth.userId != null",
|
|
152
|
+
allowUpdate: "false",
|
|
153
|
+
allowDelete: "auth.userId == data.userId",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const listingPolicy = policy({
|
|
157
|
+
name: "listing_access",
|
|
158
|
+
entity: "Listing",
|
|
159
|
+
allowRead: "true",
|
|
160
|
+
allowInsert: "auth.userId != null",
|
|
161
|
+
allowUpdate: "auth.userId == data.sellerId",
|
|
162
|
+
allowDelete: "auth.userId == data.sellerId",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const offerPolicy = policy({
|
|
166
|
+
name: "offer_access",
|
|
167
|
+
entity: "Offer",
|
|
168
|
+
allowRead: "true",
|
|
169
|
+
allowInsert: "auth.userId != null",
|
|
170
|
+
// Buyers can withdraw their own offer; the seller's accept/decline goes
|
|
171
|
+
// through respondToOffer (which checks ownership of the listing).
|
|
172
|
+
allowUpdate: "auth.userId == data.buyerId || auth.userId == data.sellerId",
|
|
173
|
+
allowDelete: "auth.userId == data.buyerId",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const manifest = buildManifest({
|
|
177
|
+
name: "__APP_NAME__",
|
|
178
|
+
version: "0.1.0",
|
|
179
|
+
entities: [User, Listing, Offer, Watch],
|
|
180
|
+
queries: [],
|
|
181
|
+
actions: [],
|
|
182
|
+
policies: [userPolicy, listingPolicy, offerPolicy, watchPolicy],
|
|
183
|
+
// File-based SSR routing: app/**/page.tsx. One binary serves the frontend
|
|
184
|
+
// and the API on one port.
|
|
185
|
+
routes: await discoverAppRoutes(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
189
|
+
|
|
190
|
+
export default manifest;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import { MarketProvider, useAuth } from "./MarketProvider";
|
|
7
|
+
|
|
8
|
+
// Compact sign-in state for the header. Signed out → a link to /sell (which
|
|
9
|
+
// gates with the prefilled demo login). Signed in → your name + a sign-out
|
|
10
|
+
// button. Lives behind MarketProvider like every other island.
|
|
11
|
+
function Nav() {
|
|
12
|
+
const { identity, signOut } = useAuth();
|
|
13
|
+
if (!identity) {
|
|
14
|
+
return (
|
|
15
|
+
<Link
|
|
16
|
+
href="/sell"
|
|
17
|
+
className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted"
|
|
18
|
+
>
|
|
19
|
+
Sign in
|
|
20
|
+
</Link>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex items-center gap-2 text-sm">
|
|
25
|
+
<span className="hidden text-muted-foreground sm:inline">
|
|
26
|
+
{identity.name}
|
|
27
|
+
</span>
|
|
28
|
+
<Button
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size="sm"
|
|
31
|
+
className="text-muted-foreground"
|
|
32
|
+
onClick={() => void signOut()}
|
|
33
|
+
>
|
|
34
|
+
Sign out
|
|
35
|
+
</Button>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function AuthNav() {
|
|
41
|
+
return (
|
|
42
|
+
<MarketProvider fallback={<span className="w-16" />}>
|
|
43
|
+
<Nav />
|
|
44
|
+
</MarketProvider>
|
|
45
|
+
);
|
|
46
|
+
}
|