-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdata.jsx
More file actions
628 lines (601 loc) · 38.1 KB
/
data.jsx
File metadata and controls
628 lines (601 loc) · 38.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
// data.jsx — Flourish shared data + global store (cart, orders, reviews, Q&A)
// Loaded before mobile.jsx and desktop.jsx so both prototypes share state.
// ─── Imagery (warm, artisan Unsplash crops) ───────────────────────────────
// Kept tight — six product hero shots + six seller portraits + four story heroes.
// All Unsplash, all license-friendly, queried with explicit crop params.
const IMG = {
// products
pavlova: 'https://images.unsplash.com/photo-1488477181946-6428a0291777?w=1200&q=80&auto=format&fit=crop',
lamington: 'https://images.unsplash.com/photo-1535254973040-607b474cb50d?w=900&q=80&auto=format&fit=crop',
carrot: 'https://images.unsplash.com/photo-1621303837174-89787a7d4729?w=900&q=80&auto=format&fit=crop',
banana: 'https://images.unsplash.com/photo-1622896784083-cc051313dbab?w=900&q=80&auto=format&fit=crop',
fruitcake: 'https://images.unsplash.com/photo-1606890737304-57a1ca8a5b62?w=900&q=80&auto=format&fit=crop',
pineapple: 'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=900&q=80&auto=format&fit=crop',
ginger: 'https://images.unsplash.com/photo-1607478900766-efe13248b125?w=900&q=80&auto=format&fit=crop',
lemon: 'https://images.unsplash.com/photo-1519869325930-281384150729?w=900&q=80&auto=format&fit=crop',
chocolate: 'https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=900&q=80&auto=format&fit=crop',
honey: 'https://images.unsplash.com/photo-1571115177098-24ec42ed204d?w=900&q=80&auto=format&fit=crop',
date: 'https://images.unsplash.com/photo-1486427944299-d1955d23e34d?w=900&q=80&auto=format&fit=crop',
rum: 'https://images.unsplash.com/photo-1612203985729-70726954388c?w=900&q=80&auto=format&fit=crop',
// seller portraits
malia: 'https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=400&q=80&auto=format&fit=crop',
priya: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400&q=80&auto=format&fit=crop',
margaret: 'https://images.unsplash.com/photo-1607746882042-944635dfe10e?w=400&q=80&auto=format&fit=crop',
weiling: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&q=80&auto=format&fit=crop',
hana: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=400&q=80&auto=format&fit=crop',
arihia: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=400&q=80&auto=format&fit=crop',
// story heroes (kitchens / process / ingredients)
story1: 'https://images.unsplash.com/photo-1556910103-1c02745aae4d?w=1400&q=80&auto=format&fit=crop',
story2: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=1400&q=80&auto=format&fit=crop',
story3: 'https://images.unsplash.com/photo-1486427944299-d1955d23e34d?w=1400&q=80&auto=format&fit=crop',
story4: 'https://images.unsplash.com/photo-1464195244916-405fa0a82545?w=1400&q=80&auto=format&fit=crop',
hero: 'https://images.unsplash.com/photo-1565958011703-44f9829ba187?w=1600&q=80&auto=format&fit=crop',
};
// ─── Sellers ──────────────────────────────────────────────────────────────
const SELLERS = [
{
id: 'malia', name: 'Malia Tuilagi', portrait: IMG.malia,
suburb: 'Māngere, Auckland', distanceKm: 4.2,
tradition: 'Samoan', tagline: 'Pani popo and koko alaisa, baked weekly.',
rating: 4.9, reviewCount: 142, joined: 'March 2024',
bio: 'Three generations of Tuilagi women in our Māngere kitchen. I bake the cakes my grandmother taught me — coconut buns soaked in pani popo, banana cake folded with hand-grated koko Samoa.',
},
{
id: 'priya', name: 'Priya Naidu', portrait: IMG.priya,
suburb: 'Sandringham, Auckland', distanceKm: 1.8,
tradition: 'Indian (Fijian-Indian)', tagline: 'Cardamom, ghee, and slow patience.',
rating: 4.8, reviewCount: 98, joined: 'July 2024',
bio: 'My ājī taught me that a good cake takes its time. Sooji halwa cake, eggless cardamom loaf, and a date-walnut that travels well.',
},
{
id: 'margaret', name: 'Margaret Bell', portrait: IMG.margaret,
suburb: 'Mount Eden, Auckland', distanceKm: 3.4,
tradition: 'Pākehā (English nan)', tagline: 'Boiled fruit cake, every Friday.',
rating: 5.0, reviewCount: 211, joined: 'November 2023',
bio: 'Retired schoolteacher. I bake the way my mother did in Hawke\'s Bay — proper boiled fruit cake, lemon drizzle, ginger crunch slice.',
},
{
id: 'weiling', name: 'Wei-Ling Chen', portrait: IMG.weiling,
suburb: 'Howick, Auckland', distanceKm: 11.6,
tradition: 'Taiwanese-Chinese', tagline: 'Soft, less-sweet, light as a cloud.',
rating: 4.9, reviewCount: 76, joined: 'February 2025',
bio: 'Hokkaido milk loaf, pineapple cake (鳳梨酥), red bean swiss roll. I bake the way I grew up eating — restrained, never too sweet.',
},
{
id: 'hana', name: 'Hana Brown', portrait: IMG.hana,
suburb: 'Grey Lynn, Auckland', distanceKm: 2.1,
tradition: 'Pākehā / modern artisan', tagline: 'Sourdough banana, brown butter everything.',
rating: 4.7, reviewCount: 53, joined: 'August 2025',
bio: 'Ex-pastry chef, baking from a tiny Grey Lynn villa. Discard banana loaf, brown-butter honey cake, miso chocolate.',
},
{
id: 'arihia', name: 'Arihia Ngata', portrait: IMG.arihia,
suburb: 'Tāmaki, Auckland', distanceKm: 7.8,
tradition: 'Māori', tagline: 'Rēwena, kawakawa, mānuka honey.',
rating: 4.9, reviewCount: 67, joined: 'May 2024',
bio: 'I bake with kai from my whānau\'s māra — kawakawa from the hills, mānuka honey from my uncle\'s hives. Rēwena loaves, kūmara spice cake, mānuka-honey madeira.',
},
];
// ─── Products ─────────────────────────────────────────────────────────────
const DIETARY = ['vegan','gluten-free','egg-free','dairy-free','nut-free','halal'];
const PRODUCTS = [
{ id:'p0', sellerId:'margaret', name:'Classic Kiwi Pavlova', price:38, image:IMG.pavlova, rating:4.9, reviews:96, type:'Pavlova', shelfDays:2, diet:['gluten-free','nut-free'], desc:'A proper pav — crisp shell, marshmallow centre. Topped with whipped cream and seasonal Hawke\'s Bay fruit. Best eaten the day you collect.' },
{ id:'p1', sellerId:'malia', name:'Pani Popo Coconut Loaf', price:28, image:IMG.lamington, rating:4.9, reviews:48, type:'Loaf cake', shelfDays:5, diet:['nut-free'], desc:'Soft sweet rolls baked into a loaf and drowned in coconut cream — Sunday afternoon in cake form.' },
{ id:'p2', sellerId:'malia', name:'Koko Samoa Banana Cake', price:24, image:IMG.banana, rating:4.8, reviews:31, type:'Banana cake',shelfDays:6, diet:['nut-free'], desc:'Hand-grated koko Samoa folded through ripe banana batter. Deep, almost smoky chocolate.' },
{ id:'p3', sellerId:'priya', name:'Cardamom Date Loaf', price:26, image:IMG.date, rating:4.9, reviews:52, type:'Spiced cake',shelfDays:8, diet:['egg-free'], desc:'Slow-soaked Medjool dates, green cardamom, walnut. Eggless, travels for a week.' },
{ id:'p4', sellerId:'priya', name:'Sooji Halwa Cake', price:30, image:IMG.honey, rating:4.7, reviews:24, type:'Semolina cake',shelfDays:7,desc:'Semolina cake with ghee, saffron, and pistachio. Tastes like Diwali.', diet:['egg-free'] },
{ id:'p5', sellerId:'margaret', name:'Boiled Fruit Cake', price:34, image:IMG.fruitcake, rating:5.0, reviews:78, type:'Fruit cake', shelfDays:21, diet:['nut-free'], desc:'Mum\'s recipe. Sultanas and currants simmered in tea, dark and dense, keeps a fortnight.' },
{ id:'p6', sellerId:'margaret', name:'Lemon Drizzle Loaf', price:22, image:IMG.lemon, rating:4.9, reviews:64, type:'Citrus loaf',shelfDays:5, diet:['nut-free'], desc:'Whole-egg butter cake with a sharp Meyer lemon syrup. Best on day two.' },
{ id:'p7', sellerId:'margaret', name:'Ginger Crunch Slice', price:18, image:IMG.ginger, rating:4.8, reviews:42, type:'Slice', shelfDays:10, diet:['nut-free'], desc:'Edmonds-style ginger crunch with a proper crumbly base. Cut into nine.' },
{ id:'p8', sellerId:'weiling', name:'Pineapple Cake (鳳梨酥)', price:32, image:IMG.pineapple, rating:4.9, reviews:38, type:'Pastry cake',shelfDays:14, diet:['nut-free','halal'], desc:'Buttery shortbread shell, pineapple-winter-melon jam centre. Twelve to a box.' },
{ id:'p9', sellerId:'weiling', name:'Hokkaido Milk Loaf', price:20, image:IMG.banana, rating:4.8, reviews:29, type:'Loaf cake', shelfDays:4, diet:['nut-free'], desc:'Tangzhong milk bread, just sweet enough to eat with butter or condensed milk.' },
{ id:'p10', sellerId:'hana', name:'Brown Butter Honey Cake', price:30, image:IMG.honey, rating:4.8, reviews:33, type:'Honey cake', shelfDays:6, diet:['nut-free'], desc:'Brown butter, mānuka honey, a whisper of orange zest. Glazed.' },
{ id:'p11', sellerId:'hana', name:'Miso Dark Chocolate Cake', price:36, image:IMG.chocolate, rating:4.7, reviews:21, type:'Chocolate', shelfDays:5, diet:[], desc:'Single-layer fudgy chocolate cake with white miso for depth. Salt on top.' },
{ id:'p12', sellerId:'arihia', name:'Mānuka Honey Madeira', price:28, image:IMG.honey, rating:4.9, reviews:41, type:'Madeira', shelfDays:7, diet:['nut-free'], desc:'Tight-crumbed butter cake, sweetened only with mānuka. Slice it thick.' },
{ id:'p13', sellerId:'arihia', name:'Kūmara Spice Cake', price:26, image:IMG.carrot, rating:4.8, reviews:26, type:'Spiced cake',shelfDays:6, diet:['nut-free'], desc:'Roasted kūmara, cinnamon, kawakawa-leaf cream cheese frosting. Earthy.' },
{ id:'p14', sellerId:'arihia', name:'Rēwena Loaf', price:16, image:IMG.banana, rating:4.9, reviews:55, type:'Bread loaf', shelfDays:5, diet:['vegan','dairy-free'], desc:'Traditional Māori potato sourdough. Slice, toast, butter, that\'s it.' },
{ id:'p15', sellerId:'malia', name:'Lamington Tray', price:24, image:IMG.lamington, rating:4.7, reviews:36, type:'Slice', shelfDays:4, diet:['nut-free'], desc:'Sponge, raspberry jam, chocolate dip, coconut. Twelve in a box.' },
{ id:'p16', sellerId:'priya', name:'Pineapple Upside-Down', price:24, image:IMG.pineapple, rating:4.6, reviews:18, type:'Upside-down',shelfDays:4, diet:[], desc:'Caramelised pineapple, dark rum, brown sugar. Stays moist.' },
];
const productById = (id) => PRODUCTS.find(p => p.id === id);
const sellerById = (id) => SELLERS.find(s => s.id === id);
const productsBySeller = (sid) => PRODUCTS.filter(p => p.sellerId === sid);
// ─── Stories (UC4) ────────────────────────────────────────────────────────
const STORIES = [
{
id:'s0', sellerId:'margaret', hero:IMG.pavlova,
title:'The case for a proper pavlova',
dek:'Why my mother\'s pav has more egg whites, less sugar, and never sees a piping bag.',
readMin:5, posted:'Yesterday',
body:[
'Pavlova is the cake every Kiwi family argues about. Crisp shell or soft? Cream of tartar or vinegar? Berries or kiwifruit? My mother had answers for all of them, and her pav was the one we ate on Christmas Day for sixty years running.',
'The secret, she swore, was patience with the meringue. Eight egg whites, room temperature, beaten until the sugar dissolved completely — you check by rubbing a smidge between your fingers. Grainy is not done.',
'I bake mine low and slow at 110°C for ninety minutes, then leave it in the cooling oven overnight. The shell shatters when you press it; the centre stays marshmallowy. Cream goes on the day you collect it, never before.',
'In summer, I top with strawberries and passionfruit. Autumn means feijoas from my neighbour\'s tree. Tradition with the seasons.',
],
featuredProductId:'p0',
qa:[
{ id:'q0a', user:'Sam K.', q:'Can it survive a 30-minute drive home?', a:'Yes — I pack the meringue and cream separately and you assemble at home. Comes with instructions and a bottle of feijoa coulis.' },
{ id:'q0b', user:'Priya N.', q:'Is there a smaller version? It\'s just two of us.', a:'Working on a mini-pav for two. DM me and I\'ll let you know when they\'re ready.' },
{ id:'q0c', user:'Maria L.', q:'What about a sugar-free option for diabetics?', a:'Pav really does need the sugar to hold its structure — but I do a low-sugar version with monk fruit. Texture is slightly softer but still good.' },
],
},
{
id:'s1', sellerId:'malia', hero:IMG.story1,
title:'Three generations of pani popo',
dek:'How my grandmother\'s Sunday cake taught me to bake by feel, not by recipe.',
readMin:4, posted:'2 days ago',
body:[
'My grandmother Sieni baked pani popo every Sunday in Apia. She never measured anything. The dough was right when it bounced back like a baby\'s cheek, she\'d say.',
'When my family moved to Māngere in the 80s, the recipe came with us — written nowhere, kept in her hands.',
'I bake them now in batches of eighteen, the same yeasty pillows drowning in coconut cream. The kitchen smells exactly like Apia did at 6am.',
],
featuredProductId:'p1',
qa:[
{ id:'q1', user:'Lena R.', q:'Do you ship to Wellington?', a:'Sorry — pani popo travels best within Auckland (under 4 hours from oven). Working on a frozen version!' },
{ id:'q2', user:'James W.', q:'What kind of coconut cream do you use?', a:'Kara, full-fat, never light. Makes a difference.' },
],
},
{
id:'s2', sellerId:'priya', hero:IMG.story2,
title:'Why I bake without eggs',
dek:'Cardamom, ghee, and the patience my ājī taught me in Suva.',
readMin:5, posted:'4 days ago',
body:[
'My grandmother in Suva would say a cake without eggs is not less — it\'s different. You learn to coax the rise from yoghurt, from baking soda, from time.',
'I crack open green cardamom pods one at a time. The pre-ground stuff has no soul.',
'Most of my customers are Hindu families looking for cakes that fit their kitchen. But the eggless cardamom loaf has become a favourite of everyone — even my Pākehā neighbours.',
],
featuredProductId:'p3',
qa:[
{ id:'q3', user:'Aroha M.', q:'Is the date loaf nut-free? My son has an allergy.', a:'It contains walnuts but I can do a nut-free version with 24h notice — message me before ordering.' },
],
},
{
id:'s3', sellerId:'arihia', hero:IMG.story3,
title:'Kawakawa from the hills',
dek:'Foraging the rongoā that goes into my kūmara spice cake.',
readMin:3, posted:'1 week ago',
body:[
'Every fortnight I drive to my whānau\'s land in the Waitākere foothills. Kawakawa grows wild near the stream — heart-shaped leaves, peppery, slightly numbing.',
'I steep them in cream for 24 hours, then whip the cream with cream cheese into a frosting for the kūmara spice cake.',
'The leaves carry rongoā — healing — but mostly they taste like home.',
],
featuredProductId:'p13',
qa:[],
},
{
id:'s4', sellerId:'weiling', hero:IMG.story4,
title:'Less sweet, on purpose',
dek:'Why my pineapple cakes won\'t taste the way you expect.',
readMin:4, posted:'2 weeks ago',
body:[
'In Taiwan, dessert is restrained. Sweetness is one note, not the whole song. My mother trained me to taste for balance — fruit acidity, butter richness, salt.',
'When I started selling pineapple cakes here, the first reviews said "not sweet enough." So I changed nothing, and waited.',
'The reviews now say "addictive." Trust is slow.',
],
featuredProductId:'p8',
qa:[
{ id:'q4', user:'Mei C.', q:'Do you use winter melon?', a:'Yes — 60% pineapple, 40% winter melon. Traditional ratio. Makes the jam smoother.' },
],
},
{
id:'s5', sellerId:'margaret', hero:IMG.story1,
title:'Mum\'s boiled fruit cake',
dek:'A wartime recipe that still fills my Mt Eden kitchen every Friday.',
readMin:4, posted:'3 weeks ago',
body:[
'Mum was born in 1932. She baked a boiled fruit cake every Friday for sixty years — same wooden spoon, same tin, same tea-soaked sultanas.',
'When she passed in 2019, I started baking her cake on Fridays. Just to keep the rhythm.',
'Now I sell about a dozen a week. Half my customers tell me their own mum used to bake one too.',
],
featuredProductId:'p5',
qa:[],
},
];
// ─── Reviews (UC5) ────────────────────────────────────────────────────────
const REVIEWS = [
{ id:'r0a', sellerId:'margaret', productId:'p0', user:'Caitlin H.', rating:5, date:'2 May', text:'Picked it up Saturday morning for my mum\'s birthday — the shell was perfectly crisp, the centre marshmallowy. The cream and feijoas on top were divine. This is THE pavlova.' },
{ id:'r0b', sellerId:'margaret', productId:'p0', user:'David W.', rating:5, date:'28 Apr', text:'Better than my nan\'s, and that\'s saying something. Cracks beautifully when you cut it.' },
{ id:'r0c', sellerId:'margaret', productId:'p0', user:'Olivia P.', rating:4, date:'22 Apr', text:'Gorgeous. A touch on the sweet side for me but Margaret said she can adjust — will message next time.' },
{ id:'r1', sellerId:'malia', productId:'p1', user:'Tane M.', rating:5, date:'12 Apr', text:'Tasted exactly like the ones my Aunty makes for White Sunday. The coconut cream soaks all the way through. Will order weekly.' },
{ id:'r2', sellerId:'malia', productId:'p2', user:'Sophie K.', rating:5, date:'8 Apr', text:'Best banana cake in Auckland. The koko Samoa makes it.' },
{ id:'r3', sellerId:'malia', productId:'p1', user:'Rua T.', rating:4, date:'2 Apr', text:'Beautiful, but I\'d eat the whole loaf in one sitting which is dangerous.' },
{ id:'r4', sellerId:'priya', productId:'p3', user:'Anita S.', rating:5, date:'15 Apr', text:'My mum is fussy about cardamom. She approved. That never happens.' },
{ id:'r5', sellerId:'priya', productId:'p4', user:'Henry L.', rating:5, date:'10 Apr', text:'The sooji halwa cake is what I\'ve been missing since I left Auckland for Wellington and came back. Saffron is real, not powder.' },
{ id:'r6', sellerId:'margaret', productId:'p5', user:'Eleanor P.', rating:5, date:'18 Apr', text:'Tasted like my mother\'s. I cried a little. Margaret is a treasure.' },
{ id:'r7', sellerId:'margaret', productId:'p6', user:'Dave R.', rating:5, date:'13 Apr', text:'Lemon drizzle is the platonic ideal. Bought three.' },
{ id:'r8', sellerId:'margaret', productId:'p7', user:'Maria F.', rating:4, date:'5 Apr', text:'Ginger crunch was excellent but cut a little uneven.' },
{ id:'r9', sellerId:'weiling', productId:'p8', user:'Wei H.', rating:5, date:'17 Apr', text:'Better than the ones I bring back from Taipei. The shell-to-filling ratio is perfect.' },
{ id:'r10', sellerId:'weiling', productId:'p9', user:'Aroha B.', rating:5, date:'9 Apr', text:'Hokkaido milk loaf is so soft it doesn\'t feel real.' },
{ id:'r11', sellerId:'arihia', productId:'p12', user:'James K.', rating:5, date:'14 Apr', text:'You can taste the mānuka. Madeira is dense in the right way.' },
{ id:'r12', sellerId:'arihia', productId:'p13', user:'Hinerangi T.',rating:5, date:'7 Apr', text:'Kūmara spice cake reminds me of my nan\'s. Kawakawa frosting is unreal.' },
{ id:'r13', sellerId:'hana', productId:'p11', user:'Olivia C.', rating:5, date:'11 Apr', text:'Miso chocolate. I was sceptical. I am no longer sceptical.' },
{ id:'r14', sellerId:'hana', productId:'p10', user:'Marcus B.', rating:4, date:'3 Apr', text:'Brown butter honey cake is gorgeous. A bit small for the price.' },
];
// ─── Orders (UC3) — pre-seeded mix of statuses ────────────────────────────
const DELIVERY_FEE = 5.99;
const SERVICE_FEE = 3.00;
// Pickup flow: pending → confirmed → preparing → ready (step 3) → completed (step 4)
// Delivery flow: pending → confirmed → preparing → out_for_delivery (step 3) → delivered (step 4) → completed (step 5)
// ↘ cancelled (step -1)
const ORDERS_SEED = [
{ id:'o1000', sellerId:'margaret', items:[{ productId:'p0', qty:1, note:'' }], status:'preparing', placed:'Today 10:22am', pickup:'Today 6:00pm', total:46.99, step:2, deliveryType:'delivery', deliveryFee:DELIVERY_FEE, etaMin:30 },
{ id:'o1001', sellerId:'malia', items:[{ productId:'p1', qty:1, note:'No sesame on top please' }], status:'preparing', placed:'Today 9:14am', pickup:'Today 4:00pm', total:31, step:2, etaMin:42 },
{ id:'o1002', sellerId:'priya', items:[{ productId:'p3', qty:2, note:'' }, { productId:'p4', qty:1, note:'' }], status:'confirmed', placed:'Today 8:02am', pickup:'Tomorrow 11:00am', total:85, step:1 },
{ id:'o1003', sellerId:'margaret', items:[{ productId:'p5', qty:1, note:'' }, { productId:'p7', qty:1, note:'Cut into 12 if possible' }], status:'pending', placed:'Today 7:31am', pickup:'Sat 2:00pm', total:55, step:0 },
{ id:'o1004', sellerId:'weiling', items:[{ productId:'p8', qty:1, note:'' }], status:'ready', placed:'Yesterday 3:12pm', pickup:'Today 12:30pm', total:35, step:3 },
{ id:'o1005', sellerId:'arihia', items:[{ productId:'p12', qty:1, note:'' }, { productId:'p14', qty:2, note:'' }], status:'completed', placed:'5 days ago', pickup:'3 days ago', total:63, step:4, reviewed:false },
{ id:'o1006', sellerId:'hana', items:[{ productId:'p11', qty:1, note:'' }], status:'completed', placed:'1 week ago', pickup:'1 week ago', total:39, step:4, reviewed:true },
{ id:'o1007', sellerId:'malia', items:[{ productId:'p2', qty:1, note:'' }, { productId:'p15', qty:1, note:'' }], status:'completed', placed:'2 weeks ago', pickup:'2 weeks ago', total:51, step:4, reviewed:true },
];
// ─── Global store (cart + orders + reviews + Q&A) ─────────────────────────
// Plain pub/sub — both prototypes useStore() into local React state.
const FlourishStore = (() => {
let state = {
cart: [
{ productId:'p6', qty:1, note:'' },
{ productId:'p7', qty:2, note:'Slightly less ginger if possible' },
],
orders: ORDERS_SEED.map(o => ({ ...o, items: o.items.map(i => ({...i})) })),
reviews: REVIEWS.slice(),
stories: STORIES.map(s => ({ ...s, qa: s.qa.map(q => ({...q})) })),
toasts: [],
nextOrderId: 1008,
nextToastId: 1,
};
const listeners = new Set();
const emit = () => listeners.forEach(fn => fn(state));
const setState = (patch) => {
state = typeof patch === 'function' ? patch(state) : { ...state, ...patch };
emit();
};
const toast = (msg) => {
const id = state.nextToastId++;
setState(s => ({ ...s, toasts: [...s.toasts, { id, msg }] }));
setTimeout(() => {
setState(s => ({ ...s, toasts: s.toasts.filter(t => t.id !== id) }));
}, 2400);
};
return {
get: () => state,
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
toast,
// cart
addToCart: (productId, qty=1, note='') => {
setState(s => {
const existing = s.cart.find(i => i.productId === productId);
const cart = existing
? s.cart.map(i => i.productId === productId ? { ...i, qty: i.qty + qty, note: note || i.note } : i)
: [...s.cart, { productId, qty, note }];
return { ...s, cart };
});
const p = productById(productId);
toast(`Added ${p.name} to cart`);
},
updateCartQty: (productId, qty) => setState(s => ({
...s,
cart: qty <= 0
? s.cart.filter(i => i.productId !== productId)
: s.cart.map(i => i.productId === productId ? { ...i, qty } : i),
})),
updateCartNote: (productId, note) => setState(s => ({
...s, cart: s.cart.map(i => i.productId === productId ? { ...i, note } : i),
})),
removeFromCart: (productId) => setState(s => ({ ...s, cart: s.cart.filter(i => i.productId !== productId) })),
clearCart: () => setState(s => ({ ...s, cart: [] })),
// orders
placeOrder: ({ pickup, payment, deliveryType='pickup' }) => {
const cart = state.cart;
if (!cart.length) return null;
const bySeller = {};
cart.forEach(i => {
const sid = productById(i.productId).sellerId;
(bySeller[sid] = bySeller[sid] || []).push(i);
});
const dFee = deliveryType === 'delivery' ? DELIVERY_FEE : 0;
const newOrders = Object.entries(bySeller).map(([sid, items]) => {
const subtotal = items.reduce((s,i) => s + productById(i.productId).price * i.qty, 0);
return {
id: 'o' + (state.nextOrderId++),
sellerId: sid,
items: items.map(i => ({ ...i })),
status: 'pending',
placed: 'Just now',
pickup,
total: parseFloat((subtotal + SERVICE_FEE + dFee).toFixed(2)),
step: 0,
payment,
deliveryType,
deliveryFee: dFee,
};
});
setState(s => ({ ...s, orders: [...newOrders, ...s.orders], cart: [] }));
toast('Order placed — sellers notified');
return newOrders[0];
},
cancelOrder: (orderId) => {
setState(s => ({
...s,
orders: s.orders.map(o => o.id === orderId ? { ...o, status:'cancelled', step: -1 } : o),
}));
toast('Order cancelled · refund issued to original payment');
},
advanceOrder: (orderId) => {
setState(s => ({
...s,
orders: s.orders.map(o => {
if (o.id !== orderId) return o;
const isDelivery = o.deliveryType === 'delivery';
const statuses = isDelivery
? ['pending','confirmed','preparing','out_for_delivery','delivered','completed']
: ['pending','confirmed','preparing','ready','completed'];
const next = Math.min(statuses.length - 1, (o.step ?? 0) + 1);
return { ...o, step: next, status: statuses[next] };
}),
}));
},
editOrderItems: (orderId, items) => {
const total = items.reduce((sum, i) => {
const p = productById(i.productId);
return sum + (p ? p.price * i.qty : 0);
}, 0);
setState(s => ({
...s,
orders: s.orders.map(o => o.id === orderId ? { ...o, items, total: parseFloat(total.toFixed(2)) } : o),
}));
toast('Order updated');
},
// reviews
submitReview: (orderId, { rating, text }) => {
const order = state.orders.find(o => o.id === orderId);
if (!order) return;
const product = productById(order.items[0].productId);
const review = {
id: 'r' + Date.now(),
sellerId: order.sellerId,
productId: product.id,
user: 'You',
rating, text,
date: 'Just now',
};
setState(s => ({
...s,
reviews: [review, ...s.reviews],
orders: s.orders.map(o => o.id === orderId ? { ...o, reviewed: true } : o),
}));
toast('Review posted · thank you');
},
reportReview: (reviewId) => {
toast('Review reported · our team will review within 24h');
},
// Q&A
askQuestion: (storyId, question) => {
setState(s => ({
...s,
stories: s.stories.map(st => st.id === storyId ? {
...st,
qa: [...st.qa, { id:'q'+Date.now(), user:'You', q:question, a:null, pending:true }],
} : st),
}));
toast('Question sent · the seller will reply soon');
},
};
})();
// React hook so components can read the store reactively
function useStore() {
const [s, setS] = React.useState(FlourishStore.get());
React.useEffect(() => FlourishStore.subscribe(setS), []);
return s;
}
// Cart item count helper
const cartCount = (cart) => cart.reduce((n,i) => n + i.qty, 0);
const cartTotal = (cart) => cart.reduce((n,i) => n + productById(i.productId).price * i.qty, 0);
// ─── Shared atoms (used by both prototypes) ───────────────────────────────
function StarRow({ rating, size=12, color }) {
const c = color || '#c96442';
const stars = [1,2,3,4,5].map(n => {
const fill = n <= Math.round(rating) ? c : 'rgba(0,0,0,0.12)';
return (
<svg key={n} width={size} height={size} viewBox="0 0 16 16" style={{ flex:'0 0 auto' }}>
<path d="M8 1.5l2.06 4.18 4.61.67-3.34 3.25.79 4.6L8 12l-4.12 2.2.79-4.6L1.33 6.35l4.61-.67z" fill={fill}/>
</svg>
);
});
return <div style={{ display:'flex', gap:1, alignItems:'center' }}>{stars}</div>;
}
function PriceTag({ value }) {
return <span style={{ fontVariantNumeric:'tabular-nums' }}>${value.toFixed(0)}</span>;
}
function StatusPill({ status, size='sm' }) {
const map = {
pending: { bg:'#f4ecdf', fg:'#8a6d2c', label:'Awaiting confirmation' },
confirmed: { bg:'#e7eee0', fg:'#4d6a3b', label:'Confirmed' },
preparing: { bg:'#f1e2d3', fg:'#9a5a2c', label:'Being prepared' },
ready: { bg:'#dde7d6', fg:'#3a5a2c', label:'Ready for pickup' },
out_for_delivery: { bg:'#e0eaf5', fg:'#2c5a8a', label:'On the way' },
delivered: { bg:'#c8e6c9', fg:'#1b5e20', label:'Delivered' },
completed: { bg:'#c8e6c9', fg:'#1b5e20', label:'Completed' },
cancelled: { bg:'#efe2e0', fg:'#9a3a2c', label:'Cancelled · refunded' },
};
const s = map[status] || map.pending;
const padY = size === 'lg' ? 6 : 3;
const padX = size === 'lg' ? 12 : 8;
const fz = size === 'lg' ? 12 : 10.5;
return (
<span style={{
display:'inline-flex', alignItems:'center', gap:6,
padding:`${padY}px ${padX}px`, borderRadius: 99,
background: s.bg, color: s.fg, fontSize: fz, fontWeight: 600,
letterSpacing:'0.01em', whiteSpace:'nowrap',
}}>
<span style={{ width:6, height:6, borderRadius:99, background:s.fg, opacity:.7 }} />
{s.label}
</span>
);
}
// Animated oven progress — stepwise fill
// step: 0=pending, 1=confirmed, 2=preparing, 3=ready, 4=completed, -1=cancelled
function OvenTracker({ step=0, size=160 }) {
const fillPct = step < 0 ? 0 : Math.min(1, step / 3);
const isCompleted = fillPct === 1;
const glow = step >= 2 && step <= 3;
const w = size, h = size * 0.85;
return (
<div style={{ width:w, height:h, position:'relative' }}>
<style>{`
@keyframes flour-flicker { 0%,100% { opacity: .55 } 50% { opacity: .95 } }
@keyframes flour-rise { 0% { transform: translateY(2px) scaleY(.96) } 100% { transform: translateY(-1px) scaleY(1) } }
@keyframes ovenSparkle { 0%,100% { opacity: 0; } 50% { opacity: 1; } }
`}</style>
<svg viewBox="0 0 200 170" width={w} height={h}>
<defs>
<linearGradient id="ovenWarm" x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stopColor="#f4a05c" />
<stop offset="60%" stopColor="#e6c98c" />
<stop offset="100%" stopColor="#fbe6c2" stopOpacity=".4" />
</linearGradient>
<clipPath id="ovenInside">
<rect x="28" y="34" width="144" height="100" rx="6" />
</clipPath>
</defs>
{/* oven body */}
<rect x="18" y="20" width="164" height="138" rx="14" fill="#2a251f" />
<rect x="22" y="24" width="156" height="130" rx="11" fill="#1f1b15" />
{/* glass door inset */}
<rect x="28" y="34" width="144" height="100" rx="6" fill="#0f0c08" />
{/* warmth fill (clipped) */}
<g clipPath="url(#ovenInside)">
<rect x="28" y={34 + 100 * (1 - fillPct)} width="144" height={100 * fillPct} fill="url(#ovenWarm)"
style={{ transition:'all 0.9s cubic-bezier(.4,.0,.2,1)' }} />
{glow && (
<g style={{ animation:'flour-flicker 1.6s ease-in-out infinite' }}>
<circle cx="60" cy={120 - 12 * fillPct} r="14" fill="#ffb86b" opacity=".55" />
<circle cx="140" cy={118 - 12 * fillPct} r="12" fill="#ffd29a" opacity=".45" />
</g>
)}
{/* cake silhouette inside */}
{step >= 1 && (
<g style={{ transformOrigin:'100px 120px', animation:'flour-rise 2.4s ease-in-out infinite alternate' }}>
<ellipse cx="100" cy="118" rx="32" ry="6" fill="#3a2a1c" opacity=".35" />
<path d="M68 120 Q70 92 100 92 Q130 92 132 120 Z" fill="#5a3a22" />
<path d="M68 120 Q70 100 100 100 Q130 100 132 120 Z" fill="#7a5538" />
{step >= 3 && (
<ellipse cx="100" cy="92" rx="30" ry="6" fill="#f0d49a" />
)}
</g>
)}
</g>
{/* door handle */}
<rect x="38" y="146" width="124" height="6" rx="3" fill="#3a342c" />
{/* knobs */}
<circle cx="160" cy="44" r="5" fill="#3a342c" />
<circle cx="160" cy="60" r="5" fill="#3a342c" />
{/* status dot */}
<circle cx="160" cy="44" r="2" fill={step >= 1 ? '#e6c98c' : '#5a4a3a'} />
<circle cx="160" cy="60" r="2" fill={step >= 2 ? '#f4a05c' : '#5a4a3a'} />
{/* completion sparkles */}
{isCompleted && (
<>
<circle cx="70" cy="8" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0s' }} />
<circle cx="130" cy="8" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0.15s' }} />
<circle cx="190" cy="35" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0.3s' }} />
<circle cx="195" cy="85" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0.45s' }} />
<circle cx="50" cy="50" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0.6s' }} />
<circle cx="100" cy="170" r="3" fill="#81c784" style={{ animation:'ovenSparkle 1.2s ease-in-out infinite', animationDelay:'0.75s' }} />
</>
)}
</svg>
</div>
);
}
function OvenSteps({ step=0, deliveryType='pickup' }) {
const isDelivery = deliveryType === 'delivery';
const steps = isDelivery
? ['Order placed','Confirmed','Being prepared','On the way','Delivered','Completed']
: ['Order placed','Confirmed','Being prepared','Ready for pickup','Completed'];
const maxStep = steps.length - 1;
return (
<ol style={{ listStyle:'none', margin:0, padding:0, display:'flex', flexDirection:'column', gap:14 }}>
{steps.map((label, i) => {
const done = step > i || (i === maxStep && step === maxStep);
const active = step === i && step !== maxStep;
return (
<li key={i} style={{ display:'flex', alignItems:'center', gap:12 }}>
<div style={{
width:22, height:22, borderRadius:99,
background: done ? (i === maxStep ? '#81c784' : '#c96442') : active ? '#fff' : 'transparent',
border: `1.5px solid ${done ? (i === maxStep ? '#81c784' : '#c96442') : active ? '#c96442' : 'rgba(0,0,0,0.18)'}`,
display:'flex', alignItems:'center', justifyContent:'center',
flex:'0 0 auto', transition:'all .3s',
}}>
{done && <svg width="11" height="11" viewBox="0 0 12 12"><path d="M2 6.5L5 9.5L10 3.5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round"/></svg>}
{active && !done && <span style={{ width:8, height:8, borderRadius:99, background:'#c96442' }} />}
</div>
<span style={{
fontSize:14, fontWeight: done || active ? 600 : 400,
color: done || active ? '#29261b' : 'rgba(41,38,27,.5)',
}}>{label}</span>
</li>
);
})}
</ol>
);
}
// Toast container — used by both prototypes
function ToastStack({ toasts, position='bottom' }) {
return (
<div style={{
position:'absolute',
[position === 'bottom' ? 'bottom' : 'top']: position === 'bottom' ? 100 : 80,
left:'50%', transform:'translateX(-50%)',
display:'flex', flexDirection:'column', gap:8, zIndex:1000,
pointerEvents:'none',
}}>
{toasts.map(t => (
<div key={t.id} style={{
padding:'10px 16px', borderRadius:99,
background:'#29261b', color:'#fbf8f2',
fontSize:13, fontWeight:500, whiteSpace:'nowrap',
boxShadow:'0 8px 24px rgba(0,0,0,.18)',
animation:'flourToastIn .25s cubic-bezier(.2,.7,.3,1)',
}}>{t.msg}</div>
))}
<style>{`
@keyframes flourToastIn { from { opacity:0; transform: translateY(8px) } to { opacity:1; transform:translateY(0) } }
`}</style>
</div>
);
}
// Image with skeleton + alt fallback (warm placeholder block)
function FImage({ src, alt='', style, radius=0 }) {
const [loaded, setLoaded] = React.useState(false);
const [err, setErr] = React.useState(false);
return (
<div style={{
position:'relative', overflow:'hidden', borderRadius:radius,
background:'linear-gradient(135deg, #efe7d8 0%, #e8dcc6 100%)',
...style,
}}>
{!err && (
<img src={src} alt={alt} onLoad={()=>setLoaded(true)} onError={()=>setErr(true)} style={{
width:'100%', height:'100%', objectFit:'cover', display:'block',
opacity: loaded ? 1 : 0, transition:'opacity .35s',
}}/>
)}
{(!loaded || err) && (
<div style={{
position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center',
color:'rgba(60,50,40,.45)', fontFamily:'ui-monospace, monospace', fontSize:10,
}}>{err ? alt || 'photo' : ''}</div>
)}
</div>
);
}
Object.assign(window, {
IMG, SELLERS, PRODUCTS, STORIES, REVIEWS, DIETARY,
productById, sellerById, productsBySeller,
FlourishStore, useStore, cartCount, cartTotal,
DELIVERY_FEE, SERVICE_FEE,
StarRow, PriceTag, StatusPill, OvenTracker, OvenSteps, ToastStack, FImage,
});