Brettapps commited on
Commit
164e100
·
verified ·
1 Parent(s): 3ad43f8

Finish ebook space

Browse files
Files changed (5) hide show
  1. Dockerfile +13 -0
  2. README.md +15 -4
  3. __pycache__/app.cpython-313.pyc +0 -0
  4. app.py +712 -0
  5. requirements.txt +15 -0
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: FairDinkumPublishing
3
- emoji: 🏆
4
  colorFrom: gray
5
- colorTo: pink
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Brett Sjoberg Publishing
3
+ emoji: 🏢
4
  colorFrom: gray
5
+ colorTo: red
6
  sdk: docker
7
  pinned: false
8
+ short_description: Brett Sjoberg. Fair Dinkum Aussie AI Publishing bundle
9
  ---
10
 
11
+ Fair Dinkum Publishing is a Docker Space that shows an ebook catalog with locally generated thumbnail covers and a Stripe-ready membership signup flow.
12
+
13
+ Set these Space secrets for checkout:
14
+
15
+ - `STRIPE_SECRET_KEY`
16
+ - `STRIPE_PRICE_ID_MONTHLY`
17
+ - `STRIPE_PRICE_ID_ANNUAL` if you want the annual plan
18
+ - `STRIPE_WEBHOOK_SECRET` if you want webhook verification
19
+ - `PUBLIC_BASE_URL` if you want to override the Space URL
20
+
21
+ The Space starts with `uvicorn app:app --host 0.0.0.0 --port 7860`.
__pycache__/app.cpython-313.pyc ADDED
Binary file (33.4 kB). View file
 
app.py ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import html
5
+ import io
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from dotenv import load_dotenv
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import HTMLResponse, JSONResponse
15
+ from pydantic import BaseModel, Field
16
+ from PIL import Image, ImageDraw, ImageFont
17
+
18
+
19
+ load_dotenv()
20
+
21
+ app = FastAPI(title="Fair Dinkum Publishing")
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=["*"],
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Ebook:
33
+ slug: str
34
+ title: str
35
+ subtitle: str
36
+ description: str
37
+ category: str
38
+ audience: str
39
+ length: str
40
+ tags: tuple[str, ...]
41
+ accent: tuple[str, str]
42
+ featured: bool = False
43
+ included_in_membership: bool = True
44
+
45
+
46
+ EBOOKS: list[Ebook] = [
47
+ Ebook(
48
+ slug="fair-dinkum-launch",
49
+ title="Fair Dinkum Launch",
50
+ subtitle="A practical guide to publishing your first ebook",
51
+ description="A step-by-step playbook for planning, writing, packaging, and selling an ebook without overcomplicating the process.",
52
+ category="Publishing",
53
+ audience="First-time authors",
54
+ length="68 pages",
55
+ tags=("publishing", "launch", "workflow"),
56
+ accent=("#0E7490", "#083344"),
57
+ featured=True,
58
+ ),
59
+ Ebook(
60
+ slug="stripe-membership-playbook",
61
+ title="Stripe Membership Playbook",
62
+ subtitle="Set up recurring revenue for a content library",
63
+ description="Design a subscription offer, set up checkout, and keep access simple for readers who want ongoing membership value.",
64
+ category="Payments",
65
+ audience="Indie publishers",
66
+ length="44 pages",
67
+ tags=("stripe", "subscription", "membership"),
68
+ accent=("#B45309", "#451A03"),
69
+ ),
70
+ Ebook(
71
+ slug="ebook-thumbnail-system",
72
+ title="Ebook Thumbnail System",
73
+ subtitle="Create covers that look good in a grid",
74
+ description="A compact visual system for making thumbnail-ready book covers, consistent series art, and better click-through on a catalog page.",
75
+ category="Design",
76
+ audience="Self-publishers",
77
+ length="52 pages",
78
+ tags=("covers", "design", "thumbnails"),
79
+ accent=("#0F766E", "#134E4A"),
80
+ ),
81
+ Ebook(
82
+ slug="aussie-author-offer",
83
+ title="Aussie Author Offer",
84
+ subtitle="Turn one ebook into a subscription product",
85
+ description="Position your ebook like a product, not a one-off file, so readers understand why membership is more valuable than a single purchase.",
86
+ category="Marketing",
87
+ audience="Creators and coaches",
88
+ length="61 pages",
89
+ tags=("pricing", "offer", "marketing"),
90
+ accent=("#7C3AED", "#312E81"),
91
+ ),
92
+ Ebook(
93
+ slug="catalog-copy-that-converts",
94
+ title="Catalog Copy That Converts",
95
+ subtitle="Descriptions for books people actually click",
96
+ description="Write concise ebook descriptions, benefit-led blurbs, and metadata that work together on a storefront or member portal.",
97
+ category="Copywriting",
98
+ audience="Publishing teams",
99
+ length="39 pages",
100
+ tags=("copy", "catalog", "seo"),
101
+ accent=("#C2410C", "#7C2D12"),
102
+ ),
103
+ Ebook(
104
+ slug="member-welcome-kit",
105
+ title="Member Welcome Kit",
106
+ subtitle="Onboard subscribers without friction",
107
+ description="A simple onboarding sequence for confirming payment, welcoming members, and helping them find the first thing to read.",
108
+ category="Operations",
109
+ audience="Subscription sites",
110
+ length="35 pages",
111
+ tags=("onboarding", "ops", "retention"),
112
+ accent=("#14532D", "#052E16"),
113
+ featured=True,
114
+ ),
115
+ ]
116
+
117
+
118
+ def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
119
+ candidates = [
120
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
121
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
122
+ ]
123
+ for path in candidates:
124
+ if os.path.exists(path):
125
+ return ImageFont.truetype(path, size=size)
126
+ return ImageFont.load_default()
127
+
128
+
129
+ def _wrap_lines(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> list[str]:
130
+ words = text.split()
131
+ lines: list[str] = []
132
+ current = ""
133
+ for word in words:
134
+ trial = f"{current} {word}".strip()
135
+ if draw.textbbox((0, 0), trial, font=font)[2] <= max_width or not current:
136
+ current = trial
137
+ else:
138
+ lines.append(current)
139
+ current = word
140
+ if current:
141
+ lines.append(current)
142
+ return lines
143
+
144
+
145
+ def build_thumbnail(ebook: Ebook) -> str:
146
+ width, height = 900, 1260
147
+ img = Image.new("RGB", (width, height), ebook.accent[1])
148
+ draw = ImageDraw.Draw(img)
149
+ top = Image.new("RGB", (width, height // 2), ebook.accent[0])
150
+ img.paste(top, (0, 0))
151
+
152
+ # Add a subtle geometric accent without relying on external artwork.
153
+ draw.rounded_rectangle((48, 48, width - 48, height - 48), radius=42, outline=(255, 255, 255), width=4)
154
+ draw.rounded_rectangle((92, 92, width - 92, height - 92), radius=30, outline=(255, 255, 255, 180), width=2)
155
+ draw.line((92, 330, width - 92, 330), fill=(255, 255, 255), width=2)
156
+
157
+ label_font = _font(38, bold=True)
158
+ title_font = _font(80, bold=True)
159
+ subtitle_font = _font(44, bold=False)
160
+ meta_font = _font(34, bold=False)
161
+
162
+ draw.text((110, 130), "FAIR DINKUM", font=label_font, fill=(255, 255, 255))
163
+ draw.text((110, 410), ebook.title, font=title_font, fill=(255, 255, 255), spacing=10)
164
+
165
+ subtitle_lines = _wrap_lines(draw, ebook.subtitle, subtitle_font, width - 220)
166
+ y = 570
167
+ for line in subtitle_lines[:4]:
168
+ draw.text((110, y), line, font=subtitle_font, fill=(247, 250, 252))
169
+ y += 62
170
+
171
+ draw.text((110, 1040), ebook.category.upper(), font=meta_font, fill=(255, 255, 255))
172
+ draw.text((110, 1100), ebook.length, font=meta_font, fill=(255, 255, 255))
173
+
174
+ with io.BytesIO() as buffer:
175
+ img.save(buffer, format="PNG", optimize=True)
176
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
177
+
178
+
179
+ THUMBNAILS = {ebook.slug: build_thumbnail(ebook) for ebook in EBOOKS}
180
+
181
+
182
+ class CheckoutRequest(BaseModel):
183
+ email: str
184
+ name: str | None = Field(default=None, max_length=120)
185
+ plan: str = Field(default="monthly")
186
+
187
+
188
+ def price_id_for_plan(plan: str) -> str | None:
189
+ if plan == "annual":
190
+ return os.getenv("STRIPE_PRICE_ID_ANNUAL")
191
+ return os.getenv("STRIPE_PRICE_ID_MONTHLY")
192
+
193
+
194
+ def stripe_configured() -> bool:
195
+ return bool(os.getenv("STRIPE_SECRET_KEY") and price_id_for_plan("monthly"))
196
+
197
+
198
+ def base_url(request: Request) -> str:
199
+ env_url = os.getenv("PUBLIC_BASE_URL")
200
+ if env_url:
201
+ return env_url.rstrip("/")
202
+ return str(request.base_url).rstrip("/")
203
+
204
+
205
+ def escape(s: Any) -> str:
206
+ return html.escape("" if s is None else str(s))
207
+
208
+
209
+ def render_page(request: Request) -> str:
210
+ base = base_url(request)
211
+ featured = [book for book in EBOOKS if book.featured]
212
+
213
+ cards = []
214
+ for ebook in EBOOKS:
215
+ tags = "".join(f'<span class="tag">{escape(tag)}</span>' for tag in ebook.tags)
216
+ featured_badge = '<span class="badge badge-featured">Featured</span>' if ebook.featured else ""
217
+ membership_badge = '<span class="badge badge-included">Included in membership</span>' if ebook.included_in_membership else ""
218
+ cards.append(
219
+ f"""
220
+ <article class="book-card" data-title="{escape((ebook.title + ' ' + ebook.subtitle + ' ' + ' '.join(ebook.tags)).lower())}"
221
+ data-category="{escape(ebook.category.lower())}"
222
+ data-tags="{escape(' '.join(ebook.tags).lower())}">
223
+ <img class="cover" src="data:image/png;base64,{THUMBNAILS[ebook.slug]}" alt="{escape(ebook.title)} cover">
224
+ <div class="book-body">
225
+ <div class="card-topline">
226
+ <span class="category">{escape(ebook.category)}</span>
227
+ {featured_badge}
228
+ {membership_badge}
229
+ </div>
230
+ <h3>{escape(ebook.title)}</h3>
231
+ <p class="subtitle">{escape(ebook.subtitle)}</p>
232
+ <p class="description">{escape(ebook.description)}</p>
233
+ <div class="meta">
234
+ <span>{escape(ebook.audience)}</span>
235
+ <span>{escape(ebook.length)}</span>
236
+ </div>
237
+ <div class="tag-row">{tags}</div>
238
+ </div>
239
+ </article>
240
+ """
241
+ )
242
+
243
+ status_line = "Stripe is configured for live checkout" if stripe_configured() else "Stripe is not configured yet"
244
+ featured_title = featured[0].title if featured else "Fair Dinkum Publishing"
245
+
246
+ return f"""
247
+ <!doctype html>
248
+ <html lang="en-AU">
249
+ <head>
250
+ <meta charset="utf-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1">
252
+ <title>Fair Dinkum Publishing</title>
253
+ <style>
254
+ :root {{
255
+ --bg: #f4f1ea;
256
+ --panel: #ffffff;
257
+ --ink: #12303f;
258
+ --muted: #5d6b74;
259
+ --line: #d8ddd9;
260
+ --accent: #0f766e;
261
+ --accent2: #b45309;
262
+ --shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
263
+ --radius: 10px;
264
+ }}
265
+ * {{ box-sizing: border-box; }}
266
+ body {{
267
+ margin: 0;
268
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
269
+ background: linear-gradient(180deg, #f4f1ea 0%, #f8faf8 100%);
270
+ color: var(--ink);
271
+ }}
272
+ a {{ color: inherit; text-decoration: none; }}
273
+ .shell {{ max-width: 1240px; margin: 0 auto; padding: 24px; }}
274
+ .hero {{
275
+ padding: 28px 0 18px;
276
+ display: grid;
277
+ grid-template-columns: 1.3fr 0.7fr;
278
+ gap: 20px;
279
+ align-items: end;
280
+ }}
281
+ .eyebrow {{ color: var(--accent); text-transform: uppercase; font-size: 12px; letter-spacing: 0.08em; font-weight: 700; }}
282
+ h1 {{ font-size: clamp(38px, 4vw, 64px); line-height: 1; margin: 8px 0 14px; max-width: 12ch; }}
283
+ .lede {{ font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 60ch; }}
284
+ .hero-card {{
285
+ background: var(--panel);
286
+ border: 1px solid var(--line);
287
+ border-radius: var(--radius);
288
+ box-shadow: var(--shadow);
289
+ padding: 20px;
290
+ }}
291
+ .hero-grid {{
292
+ display: grid;
293
+ grid-template-columns: repeat(3, minmax(0, 1fr));
294
+ gap: 10px;
295
+ margin-top: 18px;
296
+ }}
297
+ .stat {{
298
+ padding: 14px;
299
+ border: 1px solid var(--line);
300
+ border-radius: 8px;
301
+ background: #fff;
302
+ }}
303
+ .stat strong {{ display: block; font-size: 24px; }}
304
+ .stat span {{ color: var(--muted); font-size: 13px; }}
305
+ .actions {{ display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }}
306
+ .btn {{
307
+ display: inline-flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ min-height: 44px;
311
+ padding: 0 16px;
312
+ border-radius: 8px;
313
+ border: 1px solid transparent;
314
+ font-weight: 700;
315
+ cursor: pointer;
316
+ }}
317
+ .btn-primary {{ background: var(--ink); color: #fff; }}
318
+ .btn-secondary {{ background: #fff; border-color: var(--line); }}
319
+ .panel {{
320
+ margin-top: 24px;
321
+ background: var(--panel);
322
+ border: 1px solid var(--line);
323
+ border-radius: var(--radius);
324
+ box-shadow: var(--shadow);
325
+ padding: 18px;
326
+ }}
327
+ .panel-head {{
328
+ display: flex;
329
+ justify-content: space-between;
330
+ gap: 12px;
331
+ flex-wrap: wrap;
332
+ align-items: center;
333
+ margin-bottom: 14px;
334
+ }}
335
+ .searchbar {{
336
+ width: min(100%, 360px);
337
+ border: 1px solid var(--line);
338
+ border-radius: 999px;
339
+ padding: 12px 16px;
340
+ background: #fff;
341
+ font: inherit;
342
+ }}
343
+ .chips {{ display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }}
344
+ .chip {{
345
+ border: 1px solid var(--line);
346
+ background: #fff;
347
+ border-radius: 999px;
348
+ padding: 8px 12px;
349
+ font-size: 14px;
350
+ cursor: pointer;
351
+ }}
352
+ .chip.active {{ background: var(--ink); color: #fff; border-color: var(--ink); }}
353
+ .grid {{
354
+ display: grid;
355
+ grid-template-columns: repeat(3, minmax(0, 1fr));
356
+ gap: 16px;
357
+ }}
358
+ .book-card {{
359
+ border: 1px solid var(--line);
360
+ border-radius: var(--radius);
361
+ overflow: hidden;
362
+ background: #fff;
363
+ display: flex;
364
+ flex-direction: column;
365
+ }}
366
+ .cover {{ display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }}
367
+ .book-body {{ padding: 14px; display: grid; gap: 10px; }}
368
+ .card-topline {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
369
+ .category, .badge, .tag {{
370
+ display: inline-flex;
371
+ align-items: center;
372
+ border-radius: 999px;
373
+ padding: 6px 10px;
374
+ font-size: 12px;
375
+ font-weight: 700;
376
+ }}
377
+ .category {{ background: #ecfeff; color: #0f766e; }}
378
+ .badge-featured {{ background: #fff7ed; color: #9a3412; }}
379
+ .badge-included {{ background: #eef2ff; color: #4338ca; }}
380
+ .book-body h3 {{ margin: 0; font-size: 20px; line-height: 1.15; }}
381
+ .subtitle {{ margin: 0; color: var(--accent2); font-weight: 700; }}
382
+ .description {{ margin: 0; color: var(--muted); line-height: 1.5; }}
383
+ .meta {{ display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 13px; }}
384
+ .tag-row {{ display: flex; flex-wrap: wrap; gap: 8px; }}
385
+ .tag {{ background: #f8fafc; color: #41515b; font-weight: 600; }}
386
+ .membership {{
387
+ display: grid;
388
+ grid-template-columns: 1fr 1fr;
389
+ gap: 16px;
390
+ align-items: start;
391
+ }}
392
+ .field-grid {{
393
+ display: grid;
394
+ grid-template-columns: 1fr 1fr;
395
+ gap: 12px;
396
+ }}
397
+ .field, select, input {{
398
+ width: 100%;
399
+ border: 1px solid var(--line);
400
+ border-radius: 8px;
401
+ padding: 12px 14px;
402
+ font: inherit;
403
+ background: #fff;
404
+ }}
405
+ .inline-note {{ color: var(--muted); font-size: 13px; line-height: 1.5; }}
406
+ .message {{
407
+ margin-top: 12px;
408
+ padding: 12px 14px;
409
+ border-radius: 8px;
410
+ background: #f8fafc;
411
+ border: 1px solid var(--line);
412
+ color: var(--muted);
413
+ min-height: 48px;
414
+ }}
415
+ .footer {{
416
+ padding: 20px 0 8px;
417
+ color: var(--muted);
418
+ font-size: 13px;
419
+ display: flex;
420
+ justify-content: space-between;
421
+ gap: 12px;
422
+ flex-wrap: wrap;
423
+ }}
424
+ @media (max-width: 980px) {{
425
+ .hero, .grid, .membership {{ grid-template-columns: 1fr; }}
426
+ }}
427
+ @media (max-width: 720px) {{
428
+ .shell {{ padding: 16px; }}
429
+ .hero-grid, .field-grid {{ grid-template-columns: 1fr; }}
430
+ h1 {{ max-width: none; }}
431
+ }}
432
+ </style>
433
+ </head>
434
+ <body>
435
+ <main class="shell">
436
+ <section class="hero">
437
+ <div>
438
+ <div class="eyebrow">Fair Dinkum Publishing</div>
439
+ <h1>Ebook catalog built for members.</h1>
440
+ <p class="lede">Browse thumbnail-ready ebook covers, read short descriptions, and join a subscription that can be wired to Stripe Checkout when you set the pricing keys in Space secrets.</p>
441
+ <div class="actions">
442
+ <a class="btn btn-primary" href="#membership">Join membership</a>
443
+ <a class="btn btn-secondary" href="#catalog">Browse library</a>
444
+ </div>
445
+ <div class="hero-grid">
446
+ <div class="stat"><strong>{len(EBOOKS)}</strong><span>ebooks in the library</span></div>
447
+ <div class="stat"><strong>{len(featured)}</strong><span>featured releases</span></div>
448
+ <div class="stat"><strong>Stripe</strong><span>{escape(status_line)}</span></div>
449
+ </div>
450
+ </div>
451
+ <aside class="hero-card">
452
+ <div class="eyebrow">Featured release</div>
453
+ <h2 style="margin: 8px 0 10px;">{escape(featured_title)}</h2>
454
+ <p class="inline-note">Use the catalog below to surface cover art, blurbs, and membership value in one place. Each thumbnail is generated locally so the Space has a visual baseline even before you upload final covers.</p>
455
+ </aside>
456
+ </section>
457
+
458
+ <section class="panel" id="catalog">
459
+ <div class="panel-head">
460
+ <div>
461
+ <div class="eyebrow">Catalog</div>
462
+ <h2 style="margin: 6px 0 0;">Ebook thumbnails and descriptions</h2>
463
+ </div>
464
+ <input id="search" class="searchbar" type="search" placeholder="Search title, category, or tag">
465
+ </div>
466
+ <div class="chips" id="chips">
467
+ <button class="chip active" data-filter="all" type="button">All</button>
468
+ <button class="chip" data-filter="publishing" type="button">Publishing</button>
469
+ <button class="chip" data-filter="payments" type="button">Payments</button>
470
+ <button class="chip" data-filter="design" type="button">Design</button>
471
+ <button class="chip" data-filter="marketing" type="button">Marketing</button>
472
+ <button class="chip" data-filter="copywriting" type="button">Copywriting</button>
473
+ <button class="chip" data-filter="operations" type="button">Operations</button>
474
+ </div>
475
+ <div class="grid" id="book-grid" style="margin-top: 16px;">
476
+ {''.join(cards)}
477
+ </div>
478
+ </section>
479
+
480
+ <section class="panel" id="membership">
481
+ <div class="membership">
482
+ <div>
483
+ <div class="eyebrow">Membership</div>
484
+ <h2 style="margin: 6px 0 10px;">Subscription signup for readers and members</h2>
485
+ <p class="inline-note">
486
+ Connect a monthly or annual Stripe Price ID in Space secrets and this form will create a Checkout Session server-side.
487
+ No secret keys are exposed to the browser.
488
+ </p>
489
+ <div class="hero-grid" style="margin-top: 18px;">
490
+ <div class="stat"><strong>Monthly</strong><span>Best for ongoing library access</span></div>
491
+ <div class="stat"><strong>Annual</strong><span>Best for committed readers</span></div>
492
+ <div class="stat"><strong>Secure</strong><span>Checkout session created on the server</span></div>
493
+ </div>
494
+ </div>
495
+ <form id="signup-form">
496
+ <div class="field-grid">
497
+ <input id="name" class="field" type="text" placeholder="Full name" autocomplete="name">
498
+ <input id="email" class="field" type="email" placeholder="Email address" autocomplete="email" required>
499
+ </div>
500
+ <div style="height: 12px;"></div>
501
+ <select id="plan">
502
+ <option value="monthly">Monthly membership</option>
503
+ <option value="annual">Annual membership</option>
504
+ </select>
505
+ <div style="height: 12px;"></div>
506
+ <button class="btn btn-primary" style="width: 100%;" type="submit">Start subscription checkout</button>
507
+ <p class="inline-note" style="margin-top: 10px;">{escape("Stripe checkout keys required: STRIPE_SECRET_KEY and STRIPE_PRICE_ID_MONTHLY, plus STRIPE_PRICE_ID_ANNUAL if you want the yearly plan.")}</p>
508
+ <div class="message" id="message">{escape(status_line)}</div>
509
+ </form>
510
+ </div>
511
+ </section>
512
+
513
+ <footer class="footer">
514
+ <span>Built for {escape(base.replace("https://", "").replace("http://", ""))}</span>
515
+ <span>Generated thumbnails, member signup, and Stripe-ready checkout</span>
516
+ </footer>
517
+ </main>
518
+
519
+ <script>
520
+ const search = document.getElementById('search');
521
+ const chips = [...document.querySelectorAll('.chip')];
522
+ const cards = [...document.querySelectorAll('.book-card')];
523
+ let activeFilter = 'all';
524
+
525
+ function matches(card) {{
526
+ const query = search.value.trim().toLowerCase();
527
+ const haystack = `${{card.dataset.title}} ${{card.dataset.category}} ${{card.dataset.tags}}`;
528
+ const filterOk = activeFilter === 'all' || card.dataset.category.includes(activeFilter);
529
+ const queryOk = !query || haystack.includes(query);
530
+ card.style.display = filterOk && queryOk ? '' : 'none';
531
+ }}
532
+
533
+ chips.forEach((chip) => {{
534
+ chip.addEventListener('click', () => {{
535
+ chips.forEach((c) => c.classList.remove('active'));
536
+ chip.classList.add('active');
537
+ activeFilter = chip.dataset.filter;
538
+ cards.forEach(matches);
539
+ }});
540
+ }});
541
+
542
+ search.addEventListener('input', () => cards.forEach(matches));
543
+
544
+ document.getElementById('signup-form').addEventListener('submit', async (event) => {{
545
+ event.preventDefault();
546
+ const message = document.getElementById('message');
547
+ const payload = {{
548
+ name: document.getElementById('name').value.trim(),
549
+ email: document.getElementById('email').value.trim(),
550
+ plan: document.getElementById('plan').value,
551
+ }};
552
+
553
+ message.textContent = 'Creating checkout session...';
554
+
555
+ try {{
556
+ const response = await fetch('/api/checkout/session', {{
557
+ method: 'POST',
558
+ headers: {{ 'Content-Type': 'application/json' }},
559
+ body: JSON.stringify(payload),
560
+ }});
561
+ const data = await response.json();
562
+ if (!response.ok) {{
563
+ throw new Error(data.detail || data.error || 'Checkout could not be created.');
564
+ }}
565
+ if (data.checkout_url) {{
566
+ window.location.href = data.checkout_url;
567
+ return;
568
+ }}
569
+ message.textContent = data.message || 'Checkout is not configured yet.';
570
+ }} catch (error) {{
571
+ message.textContent = error.message;
572
+ }}
573
+ }});
574
+ </script>
575
+ </body>
576
+ </html>
577
+ """
578
+
579
+
580
+ @app.get("/", response_class=HTMLResponse)
581
+ async def home(request: Request) -> HTMLResponse:
582
+ return HTMLResponse(render_page(request))
583
+
584
+
585
+ @app.get("/api/health")
586
+ async def health() -> dict[str, Any]:
587
+ return {
588
+ "status": "ok",
589
+ "books": len(EBOOKS),
590
+ "stripe_configured": stripe_configured(),
591
+ }
592
+
593
+
594
+ @app.get("/api/books")
595
+ async def books() -> dict[str, Any]:
596
+ return {
597
+ "books": [
598
+ {
599
+ "slug": book.slug,
600
+ "title": book.title,
601
+ "subtitle": book.subtitle,
602
+ "description": book.description,
603
+ "category": book.category,
604
+ "audience": book.audience,
605
+ "length": book.length,
606
+ "tags": list(book.tags),
607
+ "featured": book.featured,
608
+ "included_in_membership": book.included_in_membership,
609
+ }
610
+ for book in EBOOKS
611
+ ]
612
+ }
613
+
614
+
615
+ async def create_stripe_checkout(request: Request, payload: CheckoutRequest) -> dict[str, Any]:
616
+ secret = os.getenv("STRIPE_SECRET_KEY")
617
+ if not secret:
618
+ raise HTTPException(status_code=501, detail="STRIPE_SECRET_KEY is not configured.")
619
+
620
+ if "@" not in payload.email or payload.email.count("@") != 1:
621
+ raise HTTPException(status_code=400, detail="A valid email address is required.")
622
+
623
+ price_id = price_id_for_plan(payload.plan)
624
+ if not price_id:
625
+ raise HTTPException(status_code=501, detail=f"Missing Stripe price ID for plan '{payload.plan}'.")
626
+
627
+ origin = base_url(request)
628
+ success_url = f"{origin}/?success=1&session_id={{CHECKOUT_SESSION_ID}}"
629
+ cancel_url = f"{origin}/?canceled=1"
630
+
631
+ form = {
632
+ "mode": "subscription",
633
+ "success_url": success_url,
634
+ "cancel_url": cancel_url,
635
+ "customer_email": payload.email,
636
+ "line_items[0][price]": price_id,
637
+ "line_items[0][quantity]": "1",
638
+ "metadata[plan]": payload.plan,
639
+ "metadata[email]": payload.email,
640
+ "metadata[name]": payload.name or "",
641
+ }
642
+ async with httpx.AsyncClient(timeout=20.0) as client:
643
+ response = await client.post(
644
+ "https://api.stripe.com/v1/checkout/sessions",
645
+ data=form,
646
+ auth=(secret, ""),
647
+ )
648
+
649
+ if response.status_code >= 400:
650
+ raise HTTPException(status_code=response.status_code, detail=response.text)
651
+
652
+ data = response.json()
653
+ return {
654
+ "checkout_url": data.get("url"),
655
+ "id": data.get("id"),
656
+ "message": "Checkout session created.",
657
+ }
658
+
659
+
660
+ @app.post("/api/checkout/session")
661
+ async def checkout_session(request: Request, payload: CheckoutRequest) -> JSONResponse:
662
+ data = await create_stripe_checkout(request, payload)
663
+ return JSONResponse(data)
664
+
665
+
666
+ @app.post("/api/webhooks/stripe")
667
+ async def stripe_webhook(request: Request) -> JSONResponse:
668
+ webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
669
+ raw_body = await request.body()
670
+
671
+ if webhook_secret:
672
+ signature = request.headers.get("Stripe-Signature", "")
673
+ try:
674
+ timestamp, signatures = _parse_stripe_signature(signature)
675
+ _verify_stripe_signature(webhook_secret, raw_body, timestamp, signatures)
676
+ except ValueError as exc:
677
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
678
+
679
+ event = await request.json()
680
+ event_type = event.get("type")
681
+ if event_type == "checkout.session.completed":
682
+ session = event.get("data", {}).get("object", {})
683
+ print(f"Stripe checkout completed for {session.get('customer_email') or session.get('customer')}")
684
+
685
+ return JSONResponse({"received": True})
686
+
687
+
688
+ def _parse_stripe_signature(header: str) -> tuple[str, list[str]]:
689
+ parts = {}
690
+ for chunk in header.split(","):
691
+ if "=" in chunk:
692
+ key, value = chunk.split("=", 1)
693
+ parts.setdefault(key.strip(), []).append(value.strip())
694
+ if "t" not in parts or "v1" not in parts:
695
+ raise ValueError("Invalid Stripe signature header.")
696
+ return parts["t"][0], parts["v1"]
697
+
698
+
699
+ def _verify_stripe_signature(secret: str, body: bytes, timestamp: str, signatures: list[str]) -> None:
700
+ expected = hmac.new(
701
+ secret.encode("utf-8"),
702
+ msg=f"{timestamp}.".encode("utf-8") + body,
703
+ digestmod=hashlib.sha256,
704
+ ).hexdigest()
705
+ if not any(hmac.compare_digest(expected, signature) for signature in signatures):
706
+ raise ValueError("Invalid Stripe signature.")
707
+
708
+
709
+ if __name__ == "__main__":
710
+ import uvicorn
711
+
712
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ pydantic
4
+ pydantic-settings
5
+ python-dotenv
6
+ httpx
7
+ supabase
8
+ redis
9
+ sqlalchemy
10
+ psycopg[binary]
11
+ jinja2
12
+ pillow
13
+ reportlab
14
+ python-multipart
15
+ aiofiles