@pylonsync/create-pylon 0.3.273 → 0.3.275
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 +286 -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 +207 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app.ts +135 -0
- package/templates/agency/components/marketing.tsx +148 -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/declineInquiry.ts +41 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +27 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +239 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +34 -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 +414 -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/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 +214 -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,355 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Textarea } from "../ui/textarea";
|
|
8
|
+
import { Badge } from "../ui/badge";
|
|
9
|
+
import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
|
|
10
|
+
import { money, timeAgo, type Offer } from "./market";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
listingId: string;
|
|
14
|
+
sellerId: string;
|
|
15
|
+
sellerName: string;
|
|
16
|
+
title: string;
|
|
17
|
+
price: number;
|
|
18
|
+
status: "active" | "sold";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
|
|
22
|
+
|
|
23
|
+
const statusVariant: Record<string, BadgeVariant> = {
|
|
24
|
+
pending: "warning",
|
|
25
|
+
accepted: "success",
|
|
26
|
+
declined: "outline",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function Panel(props: Props) {
|
|
30
|
+
const { listingId, sellerId, price } = props;
|
|
31
|
+
const identity = useIdentity();
|
|
32
|
+
const isSeller = !!identity && identity.userId === sellerId;
|
|
33
|
+
const isSold = props.status === "sold";
|
|
34
|
+
|
|
35
|
+
// The live query — every offer on this listing, newest first. Reads are
|
|
36
|
+
// public, so this runs for signed-out visitors too; it just lights up the
|
|
37
|
+
// moment a buyer in another tab makes an offer.
|
|
38
|
+
const { data } = db.useQuery<Offer>("Offer", {
|
|
39
|
+
where: { listingId },
|
|
40
|
+
orderBy: { createdAt: "desc" },
|
|
41
|
+
});
|
|
42
|
+
const offers = data ?? [];
|
|
43
|
+
const myOffer = identity
|
|
44
|
+
? offers.find((o) => o.buyerId === identity.userId)
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
if (isSeller) {
|
|
48
|
+
return <SellerView offers={offers} />;
|
|
49
|
+
}
|
|
50
|
+
// Making an offer needs a real account — gate it (prefilled demo login).
|
|
51
|
+
return (
|
|
52
|
+
<AuthGate
|
|
53
|
+
title="Sign in to make an offer"
|
|
54
|
+
blurb="Offers are tied to a real account so the seller knows who's bidding. The demo account is prefilled — just hit Log in."
|
|
55
|
+
>
|
|
56
|
+
<BuyerView
|
|
57
|
+
{...props}
|
|
58
|
+
myOffer={myOffer}
|
|
59
|
+
isSold={isSold}
|
|
60
|
+
suggestedPrice={price}
|
|
61
|
+
/>
|
|
62
|
+
</AuthGate>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function SellerView({ offers }: { offers: Offer[] }) {
|
|
67
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
68
|
+
const [err, setErr] = useState<string | null>(null);
|
|
69
|
+
const pending = offers.filter((o) => o.status === "pending");
|
|
70
|
+
|
|
71
|
+
// Optimistic accept/decline: flip the offer's status in the local store
|
|
72
|
+
// immediately so the seller's list updates the instant they click. The
|
|
73
|
+
// server (respondToOffer) reconciles the rest — marking the listing sold and
|
|
74
|
+
// declining the sibling offers — when its broadcast lands.
|
|
75
|
+
const respondMutation = db.useMutation<{ offerId: string; accept: boolean }>(
|
|
76
|
+
"respondToOffer",
|
|
77
|
+
{
|
|
78
|
+
optimistic: (args) => {
|
|
79
|
+
const o = offers.find((x) => x.id === args.offerId);
|
|
80
|
+
return o
|
|
81
|
+
? [
|
|
82
|
+
{
|
|
83
|
+
entity: "Offer",
|
|
84
|
+
data: { ...o, status: args.accept ? "accepted" : "declined" },
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: [];
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
async function respond(offerId: string, accept: boolean) {
|
|
93
|
+
setBusy(offerId);
|
|
94
|
+
setErr(null);
|
|
95
|
+
try {
|
|
96
|
+
await respondMutation.mutate({ offerId, accept });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
setErr((e as Error).message ?? "Could not respond to offer.");
|
|
99
|
+
} finally {
|
|
100
|
+
setBusy(null);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="space-y-3">
|
|
106
|
+
<div className="flex items-center justify-between">
|
|
107
|
+
<h2 className="font-semibold">Offers on your listing</h2>
|
|
108
|
+
<Badge variant="outline">{pending.length} pending</Badge>
|
|
109
|
+
</div>
|
|
110
|
+
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
|
111
|
+
{offers.length === 0 ? (
|
|
112
|
+
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
|
113
|
+
No offers yet. They'll show up here the moment a buyer makes one —
|
|
114
|
+
live, no refresh.
|
|
115
|
+
</p>
|
|
116
|
+
) : (
|
|
117
|
+
<ul className="space-y-2">
|
|
118
|
+
{offers.map((o) => (
|
|
119
|
+
<li
|
|
120
|
+
key={o.id}
|
|
121
|
+
className="flex items-center justify-between gap-3 rounded-lg border bg-card p-3"
|
|
122
|
+
>
|
|
123
|
+
<div className="min-w-0">
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
<span className="text-lg font-semibold tabular-nums">
|
|
126
|
+
{money(o.amount)}
|
|
127
|
+
</span>
|
|
128
|
+
<Badge variant={statusVariant[o.status] ?? "outline"}>
|
|
129
|
+
{o.status}
|
|
130
|
+
</Badge>
|
|
131
|
+
</div>
|
|
132
|
+
<p className="truncate text-sm text-muted-foreground">
|
|
133
|
+
{o.buyerName} · {timeAgo(o.createdAt)}
|
|
134
|
+
{o.message ? ` · "${o.message}"` : ""}
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
{o.status === "pending" ? (
|
|
138
|
+
<div className="flex shrink-0 gap-2">
|
|
139
|
+
<Button
|
|
140
|
+
size="sm"
|
|
141
|
+
disabled={busy === o.id}
|
|
142
|
+
onClick={() => respond(o.id, true)}
|
|
143
|
+
>
|
|
144
|
+
Accept
|
|
145
|
+
</Button>
|
|
146
|
+
<Button
|
|
147
|
+
size="sm"
|
|
148
|
+
variant="outline"
|
|
149
|
+
disabled={busy === o.id}
|
|
150
|
+
onClick={() => respond(o.id, false)}
|
|
151
|
+
>
|
|
152
|
+
Decline
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
156
|
+
</li>
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function BuyerView({
|
|
165
|
+
listingId,
|
|
166
|
+
title,
|
|
167
|
+
sellerId,
|
|
168
|
+
sellerName,
|
|
169
|
+
myOffer,
|
|
170
|
+
isSold,
|
|
171
|
+
suggestedPrice,
|
|
172
|
+
}: Props & { myOffer?: Offer; isSold: boolean; suggestedPrice: number }) {
|
|
173
|
+
// Rendered inside <AuthGate>, so identity is non-null here.
|
|
174
|
+
const identity = useIdentity();
|
|
175
|
+
const userId = identity?.userId ?? "";
|
|
176
|
+
const name = identity?.name ?? "you";
|
|
177
|
+
const [amount, setAmount] = useState(String(suggestedPrice));
|
|
178
|
+
const [message, setMessage] = useState("");
|
|
179
|
+
const [err, setErr] = useState<string | null>(null);
|
|
180
|
+
|
|
181
|
+
// Local-first optimism, baked in: db.useMutation paints the Offer into the
|
|
182
|
+
// local store the instant you click (the `optimistic` ghost), so the live
|
|
183
|
+
// query below renders "Your offer" immediately — no waiting on the server,
|
|
184
|
+
// no hand-rolled state. The server's makeOffer reuses the same id (threaded
|
|
185
|
+
// as _optimisticId), so its broadcast merges in place; on failure the engine
|
|
186
|
+
// rolls the ghost back on its own.
|
|
187
|
+
const makeOffer = db.useMutation<
|
|
188
|
+
{ listingId: string; amount: number; message: string; buyerName: string },
|
|
189
|
+
{ id: string }
|
|
190
|
+
>("makeOffer", {
|
|
191
|
+
optimistic: (args, ctx) => ({
|
|
192
|
+
entity: "Offer",
|
|
193
|
+
data: {
|
|
194
|
+
id: ctx.id,
|
|
195
|
+
listingId,
|
|
196
|
+
listingTitle: title,
|
|
197
|
+
sellerId,
|
|
198
|
+
buyerId: userId,
|
|
199
|
+
buyerName: name,
|
|
200
|
+
amount: args.amount,
|
|
201
|
+
message: args.message,
|
|
202
|
+
status: "pending",
|
|
203
|
+
createdAt: ctx.now,
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Buy now: same optimistic pattern, but the ghost is an *accepted* offer at
|
|
209
|
+
// the list price — the buyer sees "🎉 Accepted" instantly while the server
|
|
210
|
+
// marks the listing sold + declines other bids.
|
|
211
|
+
const buyNow = db.useMutation<{ listingId: string; buyerName: string }, { id: string }>(
|
|
212
|
+
"buyNow",
|
|
213
|
+
{
|
|
214
|
+
optimistic: (_args, ctx) => ({
|
|
215
|
+
entity: "Offer",
|
|
216
|
+
data: {
|
|
217
|
+
id: ctx.id,
|
|
218
|
+
listingId,
|
|
219
|
+
listingTitle: title,
|
|
220
|
+
sellerId,
|
|
221
|
+
buyerId: userId,
|
|
222
|
+
buyerName: name,
|
|
223
|
+
amount: suggestedPrice,
|
|
224
|
+
message: "Bought at list price",
|
|
225
|
+
status: "accepted",
|
|
226
|
+
createdAt: ctx.now,
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
async function buy() {
|
|
233
|
+
setErr(null);
|
|
234
|
+
try {
|
|
235
|
+
await buyNow.mutate({ listingId, buyerName: name });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
setErr((e as Error).message ?? "Could not complete the purchase.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// `myOffer` now includes the optimistic ghost, so this flips the instant
|
|
242
|
+
// the offer is made.
|
|
243
|
+
if (myOffer) {
|
|
244
|
+
return (
|
|
245
|
+
<div className="space-y-2 rounded-lg border bg-card p-4">
|
|
246
|
+
<h2 className="font-semibold">Your offer</h2>
|
|
247
|
+
<div className="flex items-center gap-2">
|
|
248
|
+
<span className="text-2xl font-semibold tabular-nums">{money(myOffer.amount)}</span>
|
|
249
|
+
<Badge variant={statusVariant[myOffer.status] ?? "outline"}>
|
|
250
|
+
{myOffer.status}
|
|
251
|
+
</Badge>
|
|
252
|
+
</div>
|
|
253
|
+
<p className="text-sm text-muted-foreground">
|
|
254
|
+
{myOffer.status === "pending"
|
|
255
|
+
? `Sent to ${sellerName} — you'll see their answer here live.`
|
|
256
|
+
: myOffer.status === "accepted"
|
|
257
|
+
? "🎉 Accepted! Arrange pickup with the seller."
|
|
258
|
+
: "This offer was declined."}
|
|
259
|
+
</p>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (isSold) {
|
|
265
|
+
return (
|
|
266
|
+
<div className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
|
|
267
|
+
This item has sold.
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function submit(e: React.FormEvent) {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
const value = Number.parseFloat(amount);
|
|
275
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
276
|
+
setErr("Enter an offer amount.");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
setErr(null);
|
|
280
|
+
try {
|
|
281
|
+
// The ghost is painted synchronously here; the view has already flipped
|
|
282
|
+
// to "Your offer" by the time this awaits.
|
|
283
|
+
await makeOffer.mutate({ listingId, amount: value, message, buyerName: name });
|
|
284
|
+
} catch (e) {
|
|
285
|
+
setErr((e as Error).message ?? "Could not send offer.");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="space-y-4 rounded-lg border bg-card p-4">
|
|
291
|
+
<div className="space-y-2">
|
|
292
|
+
<Button
|
|
293
|
+
type="button"
|
|
294
|
+
onClick={buy}
|
|
295
|
+
disabled={buyNow.loading}
|
|
296
|
+
className="w-full"
|
|
297
|
+
>
|
|
298
|
+
{buyNow.loading ? "Buying…" : `Buy now — ${money(suggestedPrice)}`}
|
|
299
|
+
</Button>
|
|
300
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
301
|
+
Instant purchase at the asking price.
|
|
302
|
+
</p>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted-foreground">
|
|
306
|
+
<span className="h-px flex-1 bg-border" />
|
|
307
|
+
or make an offer
|
|
308
|
+
<span className="h-px flex-1 bg-border" />
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<form onSubmit={submit} className="space-y-3">
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
<span className="text-muted-foreground">$</span>
|
|
314
|
+
<Input
|
|
315
|
+
type="number"
|
|
316
|
+
min="1"
|
|
317
|
+
step="1"
|
|
318
|
+
value={amount}
|
|
319
|
+
onChange={(e) => setAmount(e.target.value)}
|
|
320
|
+
className="w-32"
|
|
321
|
+
aria-label="Offer amount"
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
<Textarea
|
|
325
|
+
placeholder="Add a note (optional)…"
|
|
326
|
+
value={message}
|
|
327
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
328
|
+
rows={2}
|
|
329
|
+
/>
|
|
330
|
+
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
|
331
|
+
<Button
|
|
332
|
+
type="submit"
|
|
333
|
+
variant="outline"
|
|
334
|
+
disabled={makeOffer.loading}
|
|
335
|
+
className="w-full"
|
|
336
|
+
>
|
|
337
|
+
{makeOffer.loading
|
|
338
|
+
? "Sending…"
|
|
339
|
+
: `Offer ${money(Number.parseFloat(amount) || 0)}`}
|
|
340
|
+
</Button>
|
|
341
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
342
|
+
You're bidding as <span className="font-medium">{name}</span>
|
|
343
|
+
</p>
|
|
344
|
+
</form>
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function OfferPanel(props: Props) {
|
|
350
|
+
return (
|
|
351
|
+
<MarketProvider>
|
|
352
|
+
<Panel {...props} />
|
|
353
|
+
</MarketProvider>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from "react";
|
|
4
|
+
import { ensureDemoSeed, ensureReadSession } from "./market";
|
|
5
|
+
|
|
6
|
+
// First-run convenience: if the marketplace is empty, ensure the demo account
|
|
7
|
+
// + seed a dozen listings under it, then reload once so the server-rendered
|
|
8
|
+
// grid picks them up. Guarded by a session flag so it never loops. Real apps
|
|
9
|
+
// wouldn't ship this; it just makes `pylon dev` show something on first visit.
|
|
10
|
+
export function SeedOnEmpty({ count }: { count: number }) {
|
|
11
|
+
const fired = useRef(false);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (count > 0 || fired.current) return;
|
|
14
|
+
if (sessionStorage.getItem("market:seeded") === "1") return;
|
|
15
|
+
fired.current = true;
|
|
16
|
+
sessionStorage.setItem("market:seeded", "1");
|
|
17
|
+
void (async () => {
|
|
18
|
+
await ensureReadSession();
|
|
19
|
+
await ensureDemoSeed();
|
|
20
|
+
// The seed inserts listings owned by the demo user; reload so the SSR
|
|
21
|
+
// grid renders them. The session flag prevents a reload loop.
|
|
22
|
+
window.location.reload();
|
|
23
|
+
})();
|
|
24
|
+
}, [count]);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { db, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Textarea } from "../ui/textarea";
|
|
8
|
+
import { Label } from "../ui/label";
|
|
9
|
+
import { AuthGate, MarketProvider, useIdentity } from "./MarketProvider";
|
|
10
|
+
import { makeSlug } from "./market";
|
|
11
|
+
|
|
12
|
+
const CATEGORIES = [
|
|
13
|
+
"furniture", "electronics", "cameras", "bikes", "audio", "kitchen",
|
|
14
|
+
"instruments", "outdoor", "apparel", "other",
|
|
15
|
+
];
|
|
16
|
+
const CONDITIONS = ["new", "like-new", "good", "fair"];
|
|
17
|
+
|
|
18
|
+
const selectClass =
|
|
19
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
|
20
|
+
|
|
21
|
+
function Form() {
|
|
22
|
+
// Rendered inside <AuthGate>, so identity is guaranteed non-null here.
|
|
23
|
+
const identity = useIdentity();
|
|
24
|
+
const userId = identity?.userId ?? "";
|
|
25
|
+
const name = identity?.name ?? "you";
|
|
26
|
+
const router = useRouter();
|
|
27
|
+
const [title, setTitle] = useState("");
|
|
28
|
+
const [description, setDescription] = useState("");
|
|
29
|
+
const [price, setPrice] = useState("");
|
|
30
|
+
const [category, setCategory] = useState(CATEGORIES[0]);
|
|
31
|
+
const [condition, setCondition] = useState("good");
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const [err, setErr] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
async function submit(e: React.FormEvent) {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
const value = Number.parseFloat(price);
|
|
38
|
+
if (!title.trim()) return setErr("Give your item a title.");
|
|
39
|
+
if (!Number.isFinite(value) || value < 0) return setErr("Set a price.");
|
|
40
|
+
setBusy(true);
|
|
41
|
+
setErr(null);
|
|
42
|
+
// Local-first by default — no createListing function, no opt-in
|
|
43
|
+
// optimism flag. `db.insert` paints the listing into the local store
|
|
44
|
+
// synchronously (it's in the "just listed" ticker before the network
|
|
45
|
+
// call even leaves the tab) and pushes in the background. `sellerId`
|
|
46
|
+
// is declared `field.owner()` in app.ts, so the server stamps and
|
|
47
|
+
// verifies it from the session — we send our own id only so the
|
|
48
|
+
// optimistic row is complete; a forged seller id would be rejected.
|
|
49
|
+
const seed = Math.random().toString(36).slice(2, 8);
|
|
50
|
+
const slug = makeSlug(title.trim(), seed);
|
|
51
|
+
try {
|
|
52
|
+
await db.insert("Listing", {
|
|
53
|
+
sellerId: userId,
|
|
54
|
+
sellerName: name,
|
|
55
|
+
title: title.trim(),
|
|
56
|
+
slug,
|
|
57
|
+
description: description.trim(),
|
|
58
|
+
price: Math.max(0, Math.round(value * 100) / 100),
|
|
59
|
+
category,
|
|
60
|
+
condition,
|
|
61
|
+
status: "active",
|
|
62
|
+
seed,
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
router.push(`/listing/${slug}`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
setErr((e as Error).message ?? "Could not post your listing.");
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form onSubmit={submit} className="space-y-5">
|
|
74
|
+
<div className="space-y-1.5">
|
|
75
|
+
<Label htmlFor="title">Title</Label>
|
|
76
|
+
<Input
|
|
77
|
+
id="title"
|
|
78
|
+
value={title}
|
|
79
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
80
|
+
placeholder="e.g. Herman Miller Aeron, size B"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="space-y-1.5">
|
|
84
|
+
<Label htmlFor="description">Description</Label>
|
|
85
|
+
<Textarea
|
|
86
|
+
id="description"
|
|
87
|
+
value={description}
|
|
88
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
89
|
+
placeholder="Condition details, dimensions, why you're selling…"
|
|
90
|
+
rows={4}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="grid grid-cols-2 gap-4">
|
|
94
|
+
<div className="space-y-1.5">
|
|
95
|
+
<Label htmlFor="price">Price ($)</Label>
|
|
96
|
+
<Input
|
|
97
|
+
id="price"
|
|
98
|
+
type="number"
|
|
99
|
+
min="0"
|
|
100
|
+
step="1"
|
|
101
|
+
value={price}
|
|
102
|
+
onChange={(e) => setPrice(e.target.value)}
|
|
103
|
+
placeholder="0"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="space-y-1.5">
|
|
107
|
+
<Label htmlFor="condition">Condition</Label>
|
|
108
|
+
<select
|
|
109
|
+
id="condition"
|
|
110
|
+
value={condition}
|
|
111
|
+
onChange={(e) => setCondition(e.target.value)}
|
|
112
|
+
className={selectClass}
|
|
113
|
+
>
|
|
114
|
+
{CONDITIONS.map((c) => (
|
|
115
|
+
<option key={c} value={c}>
|
|
116
|
+
{c}
|
|
117
|
+
</option>
|
|
118
|
+
))}
|
|
119
|
+
</select>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="space-y-1.5">
|
|
123
|
+
<Label htmlFor="category">Category</Label>
|
|
124
|
+
<select
|
|
125
|
+
id="category"
|
|
126
|
+
value={category}
|
|
127
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
128
|
+
className={selectClass}
|
|
129
|
+
>
|
|
130
|
+
{CATEGORIES.map((c) => (
|
|
131
|
+
<option key={c} value={c}>
|
|
132
|
+
{c}
|
|
133
|
+
</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
|
138
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
139
|
+
{busy ? "Posting…" : "Post listing"}
|
|
140
|
+
</Button>
|
|
141
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
142
|
+
Posting as <span className="font-medium">{name}</span> — buyers'
|
|
143
|
+
offers land in <a href="/me" className="underline">My Market</a>.
|
|
144
|
+
</p>
|
|
145
|
+
</form>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function SellForm() {
|
|
150
|
+
return (
|
|
151
|
+
<MarketProvider>
|
|
152
|
+
<AuthGate
|
|
153
|
+
title="Sign in to list an item"
|
|
154
|
+
blurb="Selling needs an account so your listings are tied to you. The demo account is prefilled — just hit Log in."
|
|
155
|
+
>
|
|
156
|
+
<Form />
|
|
157
|
+
</AuthGate>
|
|
158
|
+
</MarketProvider>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import { Heart } from "lucide-react";
|
|
6
|
+
import { cn } from "../ui/utils";
|
|
7
|
+
import { bootClient, readIdentity, type Watch } from "./market";
|
|
8
|
+
|
|
9
|
+
// Heart toggle that saves a listing to your private watchlist. Self-contained
|
|
10
|
+
// (no provider needed): boots the client, reads identity, and toggles the
|
|
11
|
+
// Watch row with optimistic db.insert / db.delete — the live query below flips
|
|
12
|
+
// the fill instantly. Hidden for signed-out visitors (watchlists are a
|
|
13
|
+
// logged-in feature). The mounted gate keeps db.useQuery off the SSR pass.
|
|
14
|
+
export function WatchButton(props: {
|
|
15
|
+
listingId: string;
|
|
16
|
+
listingTitle: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const [mounted, setMounted] = useState(false);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
bootClient();
|
|
22
|
+
setMounted(true);
|
|
23
|
+
}, []);
|
|
24
|
+
if (!mounted) return null;
|
|
25
|
+
return <Inner {...props} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Inner({
|
|
29
|
+
listingId,
|
|
30
|
+
listingTitle,
|
|
31
|
+
className,
|
|
32
|
+
}: {
|
|
33
|
+
listingId: string;
|
|
34
|
+
listingTitle: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const [identity, setIdentity] = useState(() => readIdentity());
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const on = () => setIdentity(readIdentity());
|
|
40
|
+
window.addEventListener("pylon-auth-changed", on);
|
|
41
|
+
window.addEventListener("storage", on);
|
|
42
|
+
return () => {
|
|
43
|
+
window.removeEventListener("pylon-auth-changed", on);
|
|
44
|
+
window.removeEventListener("storage", on);
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Policy scopes reads to the caller, so this returns only MY watch (if any)
|
|
49
|
+
// for this listing.
|
|
50
|
+
const { data } = db.useQuery<Watch>("Watch", { where: { listingId } });
|
|
51
|
+
const mine = identity ? data?.find((w) => w.userId === identity.userId) : undefined;
|
|
52
|
+
const watched = !!mine;
|
|
53
|
+
|
|
54
|
+
// No heart for signed-out visitors.
|
|
55
|
+
if (!identity) return null;
|
|
56
|
+
|
|
57
|
+
function toggle(e: React.MouseEvent) {
|
|
58
|
+
// Stop the click from bubbling into the surrounding card link.
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
if (mine) {
|
|
62
|
+
void db.delete("Watch", mine.id);
|
|
63
|
+
} else {
|
|
64
|
+
void db.insert("Watch", { userId: identity!.userId, listingId, listingTitle });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={toggle}
|
|
72
|
+
aria-pressed={watched}
|
|
73
|
+
aria-label={watched ? "Remove from watchlist" : "Save to watchlist"}
|
|
74
|
+
title={watched ? "Saved" : "Save to watchlist"}
|
|
75
|
+
className={cn(
|
|
76
|
+
"grid size-9 place-items-center rounded-full bg-background/80 backdrop-blur transition hover:bg-background",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<Heart
|
|
81
|
+
className={cn(
|
|
82
|
+
"size-5 transition",
|
|
83
|
+
watched ? "fill-rose-500 text-rose-500" : "text-foreground/70",
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
}
|