ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Show stores without staples and add “Request this store” workflow (#390)

* Handle store requests for unsupported location staples

* closer

* could we start getting users in here

* closer but test failing

* test fixed

* missed a part

* don't add new cache

* lint

* don't test server when its internal to storage

* nah that was a slightly better test

* zip is gone

* okay exported to actowiz and save session id

* passing tests

* fumpt

* fix some codex review comments

* fix up test

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
232a048f 04fdf04a

+580 -69
+1 -1
cmd/careme/web.go
··· 76 76 recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationStorage, cache, authClient) 77 77 recipeHandler.Register(appRoutes) 78 78 79 - actowiz.NewServer().Register(infraRoutes) 79 + actowiz.NewServer(locationStorage).Register(infraRoutes) 80 80 81 81 adminMux := http.NewServeMux() 82 82 adminMux.Handle("/users", users.AdminUsersPage(userStorage))
+1
docs/cache-layout.md
··· 32 32 | `recipe_feedback/` | JSON `feedback.Feedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) using `internal/recipes/feedback/model.go` (`Marshal`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) using `internal/recipes/feedback/model.go` (`Decode`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 33 33 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 34 34 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 35 + | `location-store-requests/` | JSON `{store_id, zip, requested_at}` for stores present in location search but not yet supported for staples | `internal/locations/locations.go` (`POST /locations/request-store`) | `internal/locations/locations.go` (`RequestedStoreIDs`) and operational triage from shared cache/blob storage | 35 36 | `aldi/stores/` | JSON `aldi.StoreSummary` keyed by prefixed ALDI location ID | `cmd/aldi` and `internal/aldi` cache helpers | `internal/aldi` location backend | 36 37 | `albertsons/stores/` | JSON `albertsons.StoreSummary` keyed by prefixed Albertsons-family location ID | `cmd/albertsons` and `internal/albertsons` cache helpers | `internal/albertsons` location backend | 37 38 | `albertsons/store_url_map.json` | JSON object mapping store URL to prefixed Albertsons-family location ID | `cmd/albertsons` and `internal/albertsons` cache helpers | `cmd/albertsons` incremental sync |
+44 -13
internal/actowiz/server.go
··· 1 1 package actowiz 2 2 3 3 import ( 4 + "careme/internal/routing" 5 + "context" 4 6 "encoding/json" 7 + "log/slog" 5 8 "net/http" 6 9 7 - "careme/internal/routing" 10 + "github.com/samber/lo" 8 11 ) 9 12 10 13 const scrapeIntervalDays = 7 ··· 48 51 "safeway_2781", // 11201 georgia ave DC 49 52 } 50 53 51 - type storesResponse struct { 52 - StoreIDs []string `json:"store_ids"` 53 - ScrapeIntervalDays int `json:"scrape_interval_days"` 54 + type server struct { 55 + storeIDs []string 56 + requestedStoreIDs requestedStoreIDProvider 54 57 } 55 58 56 - type server struct { 57 - storeIDs []string 59 + type requestedStoreIDProvider interface { 60 + RequestedStoreIDs(ctx context.Context) ([]string, error) 58 61 } 59 62 60 - func NewServer() *server { 63 + func NewServer(provider requestedStoreIDProvider) *server { 64 + if provider == nil { 65 + panic("must give provider") 66 + } 61 67 return &server{ 62 - storeIDs: append([]string(nil), defaultStoreIDs...), 68 + storeIDs: append([]string(nil), defaultStoreIDs...), 69 + requestedStoreIDs: provider, 63 70 } 64 71 } 65 72 ··· 67 74 mux.HandleFunc("GET /actowiz/stores.json", s.handleStores) 68 75 } 69 76 70 - func (s *server) handleStores(w http.ResponseWriter, _ *http.Request) { 77 + type storesResponse struct { 78 + Id string `json:"id"` 79 + ScrapeIntervalDays int `json:"scrape_interval_days"` 80 + } 81 + 82 + func (s *server) handleStores(w http.ResponseWriter, r *http.Request) { 83 + storeIDs, err := s.mergedStoreIDs(r.Context()) 84 + if err != nil { 85 + slog.ErrorContext(r.Context(), "failed to list requested Actowiz stores", "error", err) 86 + http.Error(w, "failed to list stores", http.StatusInternalServerError) 87 + return 88 + } 89 + 71 90 w.Header().Set("Content-Type", "application/json; charset=utf-8") 72 - if err := json.NewEncoder(w).Encode(storesResponse{ 73 - StoreIDs: s.storeIDs, 74 - ScrapeIntervalDays: scrapeIntervalDays, 75 - }); err != nil { 91 + 92 + response := lo.Map(storeIDs, func(id string, _ int) storesResponse { 93 + return storesResponse{ 94 + Id: id, 95 + ScrapeIntervalDays: scrapeIntervalDays, 96 + } 97 + }) 98 + if err := json.NewEncoder(w).Encode(response); err != nil { 76 99 http.Error(w, "failed to encode stores", http.StatusInternalServerError) 77 100 } 78 101 } 102 + 103 + func (s *server) mergedStoreIDs(ctx context.Context) ([]string, error) { 104 + requestedStoreIDs, err := s.requestedStoreIDs.RequestedStoreIDs(ctx) 105 + if err != nil { 106 + return nil, err 107 + } 108 + return lo.Uniq(append(s.storeIDs, requestedStoreIDs...)), nil 109 + }
+62 -4
internal/actowiz/server_test.go
··· 1 1 package actowiz 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "net/http" 6 7 "net/http/httptest" 8 + "slices" 7 9 "testing" 10 + 11 + "github.com/samber/lo" 8 12 ) 9 13 10 14 func TestServerRegistersStoresJSON(t *testing.T) { 11 15 t.Parallel() 12 16 13 17 mux := http.NewServeMux() 14 - NewServer().Register(mux) 18 + NewServer(fakeRequestedStoreProvider{storeIDs: []string{}}).Register(mux) 15 19 16 20 req := httptest.NewRequest(http.MethodGet, "/actowiz/stores.json", nil) 17 21 rr := httptest.NewRecorder() ··· 26 30 t.Fatalf("content type = %q, want %q", got, "application/json; charset=utf-8") 27 31 } 28 32 29 - var got storesResponse 33 + var got []storesResponse 30 34 if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { 31 35 t.Fatalf("unmarshal response: %v", err) 32 36 } 33 37 34 - if len(got.StoreIDs) < 30 { 35 - t.Fatalf("store id count = %d, want 30", len(got.StoreIDs)) 38 + if len(got) < 30 { 39 + t.Fatalf("store id count = %d, want 30", len(got)) 40 + } 41 + } 42 + 43 + func TestServerAppendsRequestedStoresAndDedupes(t *testing.T) { 44 + t.Parallel() 45 + 46 + mux := http.NewServeMux() 47 + NewServer(fakeRequestedStoreProvider{storeIDs: []string{"publix_123", "safeway_490"}}).Register(mux) 48 + 49 + req := httptest.NewRequest(http.MethodGet, "/actowiz/stores.json", nil) 50 + rr := httptest.NewRecorder() 51 + 52 + mux.ServeHTTP(rr, req) 53 + 54 + if rr.Code != http.StatusOK { 55 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) 56 + } 57 + 58 + var got []storesResponse 59 + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { 60 + t.Fatalf("unmarshal response: %v", err) 36 61 } 62 + 63 + ids := lo.Map(got, func(r storesResponse, _ int) string { 64 + return r.Id 65 + }) 66 + if !slices.Contains(ids, "publix_123") { 67 + t.Fatalf("requested store %q missing from response: %v", "publix_123", ids) 68 + } 69 + 70 + duplicateCount := 0 71 + for _, storeID := range ids { 72 + if storeID == "safeway_490" { 73 + duplicateCount++ 74 + } 75 + } 76 + if duplicateCount != 1 { 77 + t.Fatalf("store %q count = %d, want 1", "safeway_490", duplicateCount) 78 + } 79 + 80 + if ids[len(ids)-1] != "publix_123" { 81 + t.Fatalf("last store id = %q, want %q", ids[len(ids)-1], "publix_123") 82 + } 83 + } 84 + 85 + type fakeRequestedStoreProvider struct { 86 + storeIDs []string 87 + err error 88 + } 89 + 90 + func (f fakeRequestedStoreProvider) RequestedStoreIDs(context.Context) ([]string, error) { 91 + if f.err != nil { 92 + return nil, f.err 93 + } 94 + return append([]string(nil), f.storeIDs...), nil 37 95 }
+4
internal/albertsons/locations.go
··· 59 59 return IsID(locationID) 60 60 } 61 61 62 + func (*LocationBackend) HasInventory(locationID string) bool { 63 + return false 64 + } 65 + 62 66 func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 63 67 locationID = strings.TrimSpace(locationID) 64 68 if !IsID(locationID) {
+4
internal/aldi/locations.go
··· 57 57 return IsID(locationID) 58 58 } 59 59 60 + func (b *LocationBackend) HasInventory(locationID string) bool { 61 + return false 62 + } 63 + 60 64 func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 61 65 locationID = strings.TrimSpace(locationID) 62 66 if !IsID(locationID) {
+1 -1
internal/cache/azure.go
··· 23 23 var _ ListCache = (*BlobCache)(nil) 24 24 25 25 func NewBlobCache(container string) (*BlobCache, error) { 26 - // Your account name and key can be obtained from the Azure Portal. 26 + // Should come from config 27 27 accountName, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 28 28 if !ok { 29 29 return nil, fmt.Errorf("AZURE_STORAGE_ACCOUNT_NAME could not be found")
+6 -8
internal/config/config.go
··· 128 128 } 129 129 130 130 func Load() (*Config, error) { 131 - additionalStoresEnabled := envEnabled(additionalStoresEnableEnv) 132 - 133 131 config := &Config{ 134 132 AI: AIConfig{ 135 133 APIKey: os.Getenv("AI_API_KEY"), ··· 151 149 Emails: parseAdminEmails(os.Getenv("ADMIN_EMAILS")), 152 150 }, 153 151 Aldi: AldiConfig{ 154 - Enable: additionalStoresEnabled || envEnabled("ALDI_ENABLE"), 152 + Enable: envEnabled("ALDI_ENABLE"), 155 153 }, 156 154 WholeFoods: WholeFoodsConfig{ 157 - Enable: additionalStoresEnabled || envEnabled("WHOLEFOODS_ENABLE"), 155 + Enable: envEnabled("WHOLEFOODS_ENABLE"), 158 156 }, 159 157 Albertsons: AlbertsonsConfig{ 160 - Enable: additionalStoresEnabled || envEnabled("ALBERTSONS_ENABLE"), 158 + Enable: envEnabled("ALBERTSONS_ENABLE"), 161 159 }, 162 160 Publix: PublixConfig{ 163 - Enable: additionalStoresEnabled || envEnabled("PUBLIX_ENABLE"), 161 + Enable: envEnabled("PUBLIX_ENABLE"), 164 162 }, 165 163 HEB: HEBConfig{ 166 - Enable: additionalStoresEnabled || envEnabled("HEB_ENABLE"), 164 + Enable: envEnabled("HEB_ENABLE"), 167 165 }, 168 166 Walmart: WalmartConfig{ 169 167 ConsumerID: os.Getenv("WALMART_CONSUMER_ID"), ··· 180 178 } 181 179 182 180 func envEnabled(name string) bool { 183 - return os.Getenv(name) != "" 181 + return os.Getenv(name) != "false" 184 182 } 185 183 186 184 func validate(cfg *Config) error {
+11 -11
internal/config/config_test.go
··· 32 32 func TestLoadRetainsIndividualStoreFlags(t *testing.T) { 33 33 resetStoreEnvs(t) 34 34 t.Setenv("ENABLE_MOCKS", "1") 35 - t.Setenv("PUBLIX_ENABLE", "1") 35 + t.Setenv("PUBLIX_ENABLE", "false") 36 36 37 37 cfg, err := Load() 38 38 if err != nil { 39 39 t.Fatalf("Load() error = %v", err) 40 40 } 41 41 42 - if cfg.Aldi.IsEnabled() { 43 - t.Fatalf("expected ALDI to remain disabled") 42 + if !cfg.Aldi.IsEnabled() { 43 + t.Fatalf("expected ALDI to remain enabled") 44 44 } 45 - if cfg.WholeFoods.IsEnabled() { 46 - t.Fatalf("expected Whole Foods to remain disabled") 45 + if !cfg.WholeFoods.IsEnabled() { 46 + t.Fatalf("expected Whole Foods to remain enabled") 47 47 } 48 - if cfg.Albertsons.IsEnabled() { 49 - t.Fatalf("expected Albertsons to remain disabled") 48 + if !cfg.Albertsons.IsEnabled() { 49 + t.Fatalf("expected Albertsons to remain enabled") 50 50 } 51 - if !cfg.Publix.IsEnabled() { 52 - t.Fatalf("expected Publix to be enabled") 51 + if cfg.Publix.IsEnabled() { 52 + t.Fatalf("expected Publix to be disabled ") 53 53 } 54 - if cfg.HEB.IsEnabled() { 55 - t.Fatalf("expected HEB to remain disabled") 54 + if !cfg.HEB.IsEnabled() { 55 + t.Fatalf("expected HEB to remain enaabled") 56 56 } 57 57 } 58 58
+4
internal/heb/locations.go
··· 63 63 return IsID(locationID) 64 64 } 65 65 66 + func (*LocationBackend) HasInventory(locationID string) bool { 67 + return false 68 + } 69 + 66 70 func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 67 71 locationID = strings.TrimSpace(locationID) 68 72 if !IsID(locationID) {
+5
internal/kroger/locations.go
··· 21 21 return true 22 22 } 23 23 24 + // we should hide ClientWithResponses 25 + func (*ClientWithResponses) HasInventory(locationID string) bool { 26 + return true 27 + } 28 + 24 29 func (c *ClientWithResponses) GetLocationByID(ctx context.Context, locationID string) (*locationtypes.Location, error) { 25 30 resp, err := c.LocationDetailsWithResponse(ctx, locationID) 26 31 if err != nil {
+1 -1
internal/locations/albertsons_test.go
··· 62 62 } 63 63 64 64 var found bool 65 - for _, backend := range locStorage.client { 65 + for _, backend := range locStorage.clients { 66 66 if _, ok := backend.(*albertsons.LocationBackend); ok { 67 67 found = true 68 68 break
+1 -1
internal/locations/aldi_test.go
··· 62 62 } 63 63 64 64 var found bool 65 - for _, backend := range locStorage.client { 65 + for _, backend := range locStorage.clients { 66 66 if _, ok := backend.(*aldi.LocationBackend); ok { 67 67 found = true 68 68 break
+1 -1
internal/locations/heb_test.go
··· 61 61 } 62 62 63 63 var found bool 64 - for _, backend := range locStorage.client { 64 + for _, backend := range locStorage.clients { 65 65 if _, ok := backend.(*heb.LocationBackend); ok { 66 66 found = true 67 67 break
+140 -22
internal/locations/locations.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "html/template" 9 - "io" 10 9 "log/slog" 11 10 "net/http" 12 11 "net/url" 13 12 "sort" 14 13 "strconv" 14 + "strings" 15 15 "sync" 16 16 "time" 17 17 ··· 23 23 "careme/internal/heb" 24 24 "careme/internal/kroger" 25 25 "careme/internal/locations/geo" 26 - locationtypes "careme/internal/locations/types" 26 + "careme/internal/logsetup" 27 27 "careme/internal/publix" 28 28 "careme/internal/routing" 29 29 "careme/internal/seasons" 30 30 "careme/internal/templates" 31 - utypes "careme/internal/users/types" 32 31 "careme/internal/walmart" 33 32 "careme/internal/wholefoods" 33 + 34 + locationtypes "careme/internal/locations/types" 35 + 36 + utypes "careme/internal/users/types" 37 + 38 + "github.com/samber/lo" 34 39 ) 35 40 36 41 type userLookup interface { ··· 38 43 } 39 44 40 45 type locationStorage struct { 41 - client []locationBackend 46 + clients []locationBackend 42 47 zipCentroids centroidByZip 43 - cache cache.Cache 48 + cache cache.ListCache 44 49 } 45 50 46 51 // bad for rural areas if zip code is huge? 47 52 const ( 48 53 maxLocationDistanceMiles = 20.0 49 54 locationCachePrefix = "location/" 55 + storeRequestPrefix = "location-store-requests/" 50 56 ) 51 57 52 58 type locationServer struct { 53 - storage locationGetter 59 + storage locationStore 54 60 zipFetcher zipFetcher 55 61 userStorage userLookup 56 62 } ··· 58 64 type locationGetter interface { 59 65 GetLocationByID(ctx context.Context, locationID string) (*Location, error) 60 66 GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) 67 + HasInventory(locationID string) bool 61 68 } 62 69 63 70 type zipFetcher interface { ··· 69 76 IsID(locationID string) bool 70 77 } 71 78 79 + // name is terrible conflicting with locationStorage. locationStorage should become locationAggregator. 80 + type locationStore interface { 81 + locationGetter 82 + RequestStore(ctx context.Context, locationID string) error 83 + RequestedStoreIDs(ctx context.Context) ([]string, error) 84 + } 85 + 72 86 // Location is kept as an alias for compatibility with existing imports. 73 87 type Location = locationtypes.Location 74 88 ··· 76 90 ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 77 91 } 78 92 79 - func New(cfg *config.Config, c cache.Cache, centroids centroidByZip) (locationGetter, error) { 93 + func New(cfg *config.Config, c cache.ListCache, centroids centroidByZip) (locationStore, error) { 80 94 if c == nil { 81 95 return nil, fmt.Errorf("cache is required") 82 96 } ··· 110 124 } 111 125 112 126 return &locationStorage{ 113 - client: backends, 127 + clients: backends, 114 128 zipCentroids: centroids, 115 129 cache: c, 116 130 }, nil 117 131 } 118 132 119 - func NewServer(storage locationGetter, zipFetcher zipFetcher, userStorage userLookup) *locationServer { 133 + func NewServer(storage locationStore, zipFetcher zipFetcher, userStorage userLookup) *locationServer { 120 134 return &locationServer{ 121 135 storage: storage, 122 136 zipFetcher: zipFetcher, ··· 124 138 } 125 139 } 126 140 141 + func isHTMXRequest(r *http.Request) bool { 142 + return strings.EqualFold(r.Header.Get("HX-Request"), "true") 143 + } 144 + 145 + func (l *locationStorage) HasInventory(locationID string) bool { 146 + _, found := lo.Find(l.clients, func(backend locationBackend) bool { 147 + return backend.IsID(locationID) && backend.HasInventory(locationID) 148 + }) 149 + return found 150 + } 151 + 127 152 func (l *locationStorage) GetLocationByID(ctx context.Context, locationID string) (*Location, error) { 128 153 if cachedLoc, ok := l.cachedLocationByID(ctx, locationID); ok { 129 154 return &cachedLoc, nil 130 155 } 131 156 132 - for _, backend := range l.client { 157 + for _, backend := range l.clients { 133 158 if !backend.IsID(locationID) { 134 159 continue 135 160 } ··· 150 175 } 151 176 152 177 func (l *locationStorage) GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) { 153 - results := make(chan []Location, len(l.client)) 154 - errors := make(chan error, len(l.client)) 178 + results := make(chan []Location, len(l.clients)) 179 + errors := make(chan error, len(l.clients)) 155 180 var wg sync.WaitGroup 156 - for _, backend := range l.client { 181 + for _, backend := range l.clients { 157 182 wg.Add(1) 158 183 go func(backend locationBackend) { 159 184 defer wg.Done() ··· 171 196 wg.Wait() 172 197 close(results) 173 198 close(errors) 174 - if len(errors) == len(l.client) { 199 + if len(errors) == len(l.clients) { 175 200 return nil, fmt.Errorf("all backends failed to get locations for zip %s", zipcode) 176 201 } 177 202 var allLocations []Location ··· 223 248 _ = blob.Close() 224 249 }() 225 250 226 - raw, err := io.ReadAll(blob) 227 - if err != nil { 228 - slog.WarnContext(ctx, "failed to read cached location blob", "location_id", locationID, "error", err) 229 - return Location{}, false 230 - } 231 251 var loc Location 232 - if err := json.Unmarshal(raw, &loc); err != nil { 252 + if err := json.NewDecoder(blob).Decode(&loc); err != nil { 233 253 slog.WarnContext(ctx, "failed to parse cached location blob", "location_id", locationID, "error", err) 234 254 return Location{}, false 235 255 } ··· 261 281 return nil 262 282 } 263 283 284 + type locationRequest struct { 285 + StoreID string `json:"store_id"` 286 + Users []string `json:"users"` 287 + RequestedAt time.Time `json:"requested_at"` 288 + } 289 + 290 + func (l *locationStorage) RequestStore(ctx context.Context, storeID string) error { 291 + request := locationRequest{ 292 + StoreID: storeID, 293 + RequestedAt: time.Now().UTC(), 294 + } 295 + if current, err := l.cache.Get(ctx, storeRequestPrefix+storeID); err == nil { 296 + defer func() { 297 + _ = current.Close() 298 + }() 299 + var existingRequest locationRequest 300 + if err := json.NewDecoder(current).Decode(&existingRequest); err != nil { 301 + return fmt.Errorf("parse existing store request: %w", err) 302 + } 303 + request = existingRequest 304 + } else if !errors.Is(err, cache.ErrNotFound) { 305 + return fmt.Errorf("fetch existing store request: %w", err) 306 + } 307 + if sessionID, ok := logsetup.SessionIDFromContext(ctx); ok { 308 + request.Users = append(request.Users, sessionID) 309 + } 310 + 311 + raw, err := json.Marshal(request) 312 + if err != nil { 313 + return nil 314 + } 315 + requestKey := storeRequestPrefix + storeID 316 + if err := l.cache.Put(ctx, requestKey, string(raw), cache.Unconditional()); err != nil { 317 + return fmt.Errorf("store request put: %w", err) 318 + } 319 + return nil 320 + } 321 + 322 + func (l *locationStorage) RequestedStoreIDs(ctx context.Context) ([]string, error) { 323 + storeIDs, err := l.cache.List(ctx, storeRequestPrefix, "") 324 + if err != nil { 325 + return nil, fmt.Errorf("list requested stores: %w", err) 326 + } 327 + return storeIDs, nil 328 + } 329 + 264 330 func sortLocationsByDistanceFromCentroid(locations []Location, requestedCentroid locationtypes.ZipCentroid, zipCentroids centroidByZip) { 265 331 sort.SliceStable(locations, func(i, j int) bool { 266 332 leftDistance := locationDistanceTo(requestedCentroid, locations[i], zipCentroids) ··· 337 403 http.Error(w, "Failed to render locations page. ", http.StatusInternalServerError) 338 404 } 339 405 }) 406 + 407 + mux.HandleFunc("POST /locations/request-store", func(w http.ResponseWriter, r *http.Request) { 408 + if !isHTMXRequest(r) { 409 + http.Error(w, "store requests must be made via HTMX", http.StatusBadRequest) 410 + return 411 + } 412 + 413 + if err := r.ParseForm(); err != nil { 414 + http.Error(w, "invalid request", http.StatusBadRequest) 415 + return 416 + } 417 + 418 + storeID := r.FormValue("store_id") 419 + if storeID == "" { 420 + http.Error(w, "store_id is required", http.StatusBadRequest) 421 + return 422 + } 423 + 424 + ctx := r.Context() 425 + if _, err := l.storage.GetLocationByID(ctx, storeID); err != nil { 426 + http.Error(w, "invalid store_id", http.StatusBadRequest) 427 + return 428 + } 429 + if l.storage.HasInventory(storeID) { 430 + http.Error(w, "store already supported", http.StatusBadRequest) 431 + return 432 + } 433 + 434 + if err := l.storage.RequestStore(ctx, storeID); err != nil { 435 + http.Error(w, "failed to submit request", http.StatusInternalServerError) 436 + return 437 + } 438 + 439 + if err := templates.Location.ExecuteTemplate(w, "location_request_store_success", nil); err != nil { 440 + slog.ErrorContext(ctx, "failed to render request-store success fragment", "store_id", storeID, "error", err) 441 + http.Error(w, "failed to submit request", http.StatusInternalServerError) 442 + return 443 + } 444 + }) 340 445 } 341 446 342 447 func (l *locationServer) renderLocationsPage(w http.ResponseWriter, ctx context.Context, zip string, favoriteStore string, serverSignedIn bool) error { ··· 345 450 return fmt.Errorf("failed to get locations for zip %s: %w", zip, err) 346 451 } 347 452 453 + type locationRow struct { 454 + Location 455 + SupportsStaples bool 456 + } 457 + 458 + rows := make([]locationRow, 0, len(locs)) 459 + for _, loc := range locs { 460 + rows = append(rows, locationRow{ 461 + Location: loc, 462 + SupportsStaples: l.storage.HasInventory(loc.ID), 463 + }) 464 + } 465 + 348 466 data := struct { 349 - Locations []Location 467 + Locations []locationRow 350 468 Zip string 351 469 FavoriteStore string 352 470 ClarityScript template.HTML ··· 354 472 Style seasons.Style 355 473 ServerSignedIn bool 356 474 }{ 357 - Locations: locs, 475 + Locations: rows, 358 476 Zip: zip, 359 477 FavoriteStore: favoriteStore, 360 478 ClarityScript: templates.ClarityScript(ctx),
+231 -2
internal/locations/locations_test.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "io" 9 + "net/http" 10 + "net/http/httptest" 11 + "strings" 8 12 "testing" 9 13 "time" 10 14 15 + "careme/internal/auth" 11 16 cachepkg "careme/internal/cache" 17 + "careme/internal/config" 18 + "careme/internal/templates" 19 + utypes "careme/internal/users/types" 12 20 ) 13 21 14 22 func TestGetLocationByIDUsesCache(t *testing.T) { ··· 301 309 } 302 310 } 303 311 312 + func TestHasInventory(t *testing.T) { 313 + server := newTestLocationServerWithBackends([]locationBackend{ 314 + inventoryBackend{ 315 + supported: map[string]bool{ 316 + "70500874": true, 317 + "wholefoods_123": true, 318 + }, 319 + }, 320 + inventoryBackend{ 321 + supported: map[string]bool{ 322 + "walmart_123": true, 323 + }, 324 + }, 325 + }) 326 + 327 + tests := []struct { 328 + name string 329 + storeID string 330 + hasInventory bool 331 + }{ 332 + {name: "kroger", storeID: "70500874", hasInventory: true}, 333 + {name: "wholefoods", storeID: "wholefoods_123", hasInventory: true}, 334 + {name: "walmart", storeID: "walmart_123", hasInventory: true}, 335 + {name: "unsupported", storeID: "publix_123", hasInventory: false}, 336 + } 337 + 338 + for _, tt := range tests { 339 + t.Run(tt.name, func(t *testing.T) { 340 + if got := server.HasInventory(tt.storeID); got != tt.hasInventory { 341 + t.Fatalf("HasInventory(%q) = %v, want %v", tt.storeID, got, tt.hasInventory) 342 + } 343 + }) 344 + } 345 + } 346 + 347 + func TestRequestStoreWritesRequestBlob(t *testing.T) { 348 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 349 + t.Fatalf("failed to init templates: %v", err) 350 + } 351 + 352 + fc := cachepkg.NewInMemoryCache() 353 + client := newFakeLocationClient() 354 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 355 + client.setHasInventory("publix_123", false) 356 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 357 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 358 + 359 + mux := http.NewServeMux() 360 + server.Register(mux, auth.DefaultMock()) 361 + 362 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 363 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 364 + req.Header.Set("HX-Request", "true") 365 + rr := httptest.NewRecorder() 366 + mux.ServeHTTP(rr, req) 367 + 368 + if rr.Code != http.StatusOK { 369 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 370 + } 371 + if rr.Header().Get("Location") != "" { 372 + t.Fatalf("expected no redirect location, got %q", rr.Header().Get("Location")) 373 + } 374 + if body := rr.Body.String(); !strings.Contains(body, "Request sent") { 375 + t.Fatalf("expected success fragment, got %q", body) 376 + } 377 + 378 + raw := requireEventuallyCached(t, fc, storeRequestPrefix+"publix_123") 379 + var payload locationRequest 380 + if err := json.Unmarshal([]byte(raw), &payload); err != nil { 381 + t.Fatalf("failed to decode request blob: %v", err) 382 + } 383 + if payload.StoreID != "publix_123" { 384 + t.Fatalf("unexpected store id %q", payload.StoreID) 385 + } 386 + if payload.RequestedAt.IsZero() { 387 + t.Fatal("expected requested_at to be set") 388 + } 389 + } 390 + 391 + func TestRequestStoreIsIdempotent(t *testing.T) { 392 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 393 + t.Fatalf("failed to init templates: %v", err) 394 + } 395 + 396 + fc := cachepkg.NewInMemoryCache() 397 + client := newFakeLocationClient() 398 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 399 + client.setHasInventory("publix_123", false) 400 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 401 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 402 + 403 + mux := http.NewServeMux() 404 + server.Register(mux, auth.DefaultMock()) 405 + 406 + for i := 0; i < 2; i++ { 407 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 408 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 409 + req.Header.Set("HX-Request", "true") 410 + rr := httptest.NewRecorder() 411 + mux.ServeHTTP(rr, req) 412 + 413 + if rr.Code != http.StatusOK { 414 + t.Fatalf("request %d status = %d, want %d; body=%q", i+1, rr.Code, http.StatusOK, rr.Body.String()) 415 + } 416 + } 417 + } 418 + 419 + func TestRequestStoreRejectsSupportedStore(t *testing.T) { 420 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 421 + t.Fatalf("failed to init templates: %v", err) 422 + } 423 + 424 + fc := cachepkg.NewInMemoryCache() 425 + client := newFakeLocationClient() 426 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 427 + client.setHasInventory("publix_123", true) 428 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 429 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 430 + 431 + mux := http.NewServeMux() 432 + server.Register(mux, auth.DefaultMock()) 433 + 434 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 435 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 436 + req.Header.Set("HX-Request", "true") 437 + rr := httptest.NewRecorder() 438 + mux.ServeHTTP(rr, req) 439 + 440 + if rr.Code != http.StatusBadRequest { 441 + t.Fatalf("status = %d, want %d; body=%q", rr.Code, http.StatusBadRequest, rr.Body.String()) 442 + } 443 + } 444 + 445 + func TestRequestStoreReturnsWriteErrors(t *testing.T) { 446 + storage := &locationStorage{ 447 + cache: failingListCache{putErr: errors.New("boom")}, 448 + } 449 + 450 + err := storage.RequestStore(context.Background(), "publix_123") 451 + if err == nil { 452 + t.Fatal("RequestStore error = nil, want error") 453 + } 454 + } 455 + 456 + func TestRequestedStoreIDsListsStoredRequests(t *testing.T) { 457 + fc := cachepkg.NewInMemoryCache() 458 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{newFakeLocationClient()}, fc) 459 + 460 + mustPutJSONInCache(t, fc, storeRequestPrefix+"publix_123", locationRequest{StoreID: "publix_123"}) 461 + mustPutJSONInCache(t, fc, storeRequestPrefix+"walmart_456", locationRequest{StoreID: "walmart_456"}) 462 + 463 + got, err := storage.RequestedStoreIDs(context.Background()) 464 + if err != nil { 465 + t.Fatalf("RequestedStoreIDs returned error: %v", err) 466 + } 467 + 468 + if got, want := strings.Join(got, ","), "publix_123,walmart_456"; got != want { 469 + t.Fatalf("RequestedStoreIDs = %q, want %q", got, want) 470 + } 471 + } 472 + 304 473 type fakeLocationClient struct { 305 474 details map[string]Location 306 475 lists map[string][]Location 476 + inv map[string]bool 307 477 err error 308 478 } 309 479 ··· 311 481 return &fakeLocationClient{ 312 482 details: make(map[string]Location), 313 483 lists: make(map[string][]Location), 484 + inv: make(map[string]bool), 314 485 } 315 486 } 316 487 ··· 320 491 321 492 func (f *fakeLocationClient) setListResponse(zip string, locations []Location) { 322 493 f.lists[zip] = locations 494 + } 495 + 496 + func (f *fakeLocationClient) setHasInventory(locationID string, hasInventory bool) { 497 + f.inv[locationID] = hasInventory 323 498 } 324 499 325 500 func (f *fakeLocationClient) GetLocationsByZip(_ context.Context, zipcode string) ([]Location, error) { ··· 347 522 return locationID != "" 348 523 } 349 524 525 + func (f *fakeLocationClient) HasInventory(locationID string) bool { 526 + if hasInventory, ok := f.inv[locationID]; ok { 527 + return hasInventory 528 + } 529 + return true 530 + } 531 + 532 + type inventoryBackend struct { 533 + supported map[string]bool 534 + } 535 + 536 + func (b inventoryBackend) GetLocationByID(context.Context, string) (*Location, error) { 537 + return nil, fmt.Errorf("not implemented") 538 + } 539 + 540 + func (b inventoryBackend) GetLocationsByZip(context.Context, string) ([]Location, error) { 541 + return nil, nil 542 + } 543 + 544 + func (b inventoryBackend) IsID(locationID string) bool { 545 + _, ok := b.supported[locationID] 546 + return ok 547 + } 548 + 549 + func (b inventoryBackend) HasInventory(locationID string) bool { 550 + return b.supported[locationID] 551 + } 552 + 553 + type failingListCache struct { 554 + putErr error 555 + } 556 + 557 + func (f failingListCache) Get(context.Context, string) (io.ReadCloser, error) { 558 + return nil, cachepkg.ErrNotFound 559 + } 560 + 561 + func (f failingListCache) Exists(context.Context, string) (bool, error) { 562 + return false, nil 563 + } 564 + 565 + func (f failingListCache) Put(context.Context, string, string, cachepkg.PutOptions) error { 566 + return f.putErr 567 + } 568 + 569 + func (f failingListCache) List(context.Context, string, string) ([]string, error) { 570 + return nil, nil 571 + } 572 + 350 573 func newTestLocationServer(client locationBackend) *locationStorage { 351 574 return newTestLocationServerWithBackends([]locationBackend{client}) 352 575 } ··· 355 578 return newTestLocationServerWithBackendsAndCache(backends, cachepkg.NewInMemoryCache()) 356 579 } 357 580 358 - func newTestLocationServerWithBackendsAndCache(backends []locationBackend, c cachepkg.Cache) *locationStorage { 581 + func newTestLocationServerWithBackendsAndCache(backends []locationBackend, c cachepkg.ListCache) *locationStorage { 359 582 zipCentroids := LoadCentroids() 360 583 return &locationStorage{ 361 - client: backends, 584 + clients: backends, 362 585 zipCentroids: zipCentroids, 363 586 cache: c, 364 587 } ··· 407 630 } 408 631 return ts 409 632 } 633 + 634 + type fakeUserLookup struct{} 635 + 636 + func (fakeUserLookup) FromRequest(context.Context, *http.Request, auth.AuthClient) (*utypes.User, error) { 637 + return nil, auth.ErrNoSession 638 + }
+12
internal/locations/mock.go
··· 52 52 return "", false 53 53 } 54 54 55 + func (mock) HasInventory(locationID string) bool { 56 + return true 57 + } 58 + 59 + func (mock) RequestStore(ctx context.Context, locationID string) error { 60 + return nil 61 + } 62 + 63 + func (mock) RequestedStoreIDs(ctx context.Context) ([]string, error) { 64 + return nil, nil 65 + } 66 + 55 67 func (m mock) Register(mux routing.Registrar, _ auth.AuthClient) { 56 68 mux.HandleFunc("/locations", func(w http.ResponseWriter, r *http.Request) { 57 69 data := struct {
+1 -1
internal/locations/wholefoods_test.go
··· 60 60 } 61 61 62 62 var found bool 63 - for _, backend := range locStorage.client { 63 + for _, backend := range locStorage.clients { 64 64 if _, ok := backend.(*wholefoods.LocationBackend); ok { 65 65 found = true 66 66 break
+4
internal/publix/locations.go
··· 60 60 return strings.HasPrefix(locationID, LocationIDPrefix) && len(locationID) > len(LocationIDPrefix) 61 61 } 62 62 63 + func (*LocationBackend) HasInventory(locationID string) bool { 64 + return false 65 + } 66 + 63 67 func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 64 68 locationID = strings.TrimSpace(locationID) 65 69 if !b.IsID(locationID) {
+1 -1
internal/static/tailwind.css
··· 1 1 /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-700:oklch(50.5% .213 27.518);--color-amber-500:oklch(76.9% .188 70.08);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-700:oklch(50.8% .118 165.612);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700);--color-brand-800:var(--brand-800);--color-brand-900:var(--brand-900);--color-ink-500:#64748b;--color-ink-600:#475569;--color-ink-700:#334155;--color-ink-900:#0f172a;--color-action-green-50:#ecfdf5;--color-action-green-100:#d1fae5;--color-action-green-300:#6ee7b7;--color-action-green-500:#10b981;--color-action-green-600:#059669;--color-action-green-700:#047857;--color-action-red-50:#fef2f2;--color-action-red-100:#fee2e2;--color-action-red-500:#ef4444;--color-action-red-600:#dc2626;--color-action-red-700:#b91c1c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}p{text-wrap:pretty}}@layer components{.friendly-card{corner-shape:squircle;border-radius:48px}.friendly-card-soft{corner-shape:squircle;border-radius:28px}.shopping-wine-details,.shopping-recipe-card:has(details[open]) .shopping-wine-preview{display:none}.shopping-recipe-card:has(details[open]) .shopping-wine-details{display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-3{top:calc(var(--spacing)*3)}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing)*2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-action-green-500{border-color:var(--color-action-green-500)}.border-action-red-500{border-color:var(--color-action-red-500)}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-action-green-50{background-color:var(--color-action-green-50)}.bg-action-green-500{background-color:var(--color-action-green-500)}.bg-action-red-50{background-color:var(--color-action-red-50)}.bg-brand-50,.bg-brand-50\/40{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/40{background-color:color-mix(in oklab,var(--color-brand-50)40%,transparent)}}.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-50\/70{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/70{background-color:color-mix(in oklab,var(--color-brand-50)70%,transparent)}}.bg-brand-50\/80{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/80{background-color:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.bg-brand-100{background-color:var(--color-brand-100)}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/88{background-color:#ffffffe0}@supports (color:color-mix(in lab, red, red)){.bg-white\/88{background-color:color-mix(in oklab,var(--color-white)88%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-brand-50\/80{--tw-gradient-from:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.from-brand-50\/80{--tw-gradient-from:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.from-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\/72{--tw-gradient-from:#ffffffb8}@supports (color:color-mix(in lab, red, red)){.from-white\/72{--tw-gradient-from:color-mix(in oklab,var(--color-white)72%,transparent)}}.from-white\/72{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-white{--tw-gradient-via:var(--color-white);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-white\/62{--tw-gradient-via:#ffffff9e}@supports (color:color-mix(in lab, red, red)){.via-white\/62{--tw-gradient-via:color-mix(in oklab,var(--color-white)62%,transparent)}}.via-white\/62{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-brand-50\/80{--tw-gradient-to:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.to-brand-50\/80{--tw-gradient-to:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.to-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white\/78{--tw-gradient-to:#ffffffc7}@supports (color:color-mix(in lab, red, red)){.to-white\/78{--tw-gradient-to:color-mix(in oklab,var(--color-white)78%,transparent)}}.to-white\/78{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.whitespace-pre-line{white-space:pre-line}.text-action-green-700{color:var(--color-action-green-700)}.text-action-red-600{color:var(--color-action-red-600)}.text-action-red-700{color:var(--color-action-red-700)}.text-amber-500{color:var(--color-amber-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-brand-800{color:var(--color-brand-800)}.text-brand-900{color:var(--color-brand-900)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-ink-500{color:var(--color-ink-500)}.text-ink-600{color:var(--color-ink-600)}.text-ink-700{color:var(--color-ink-700)}.text-ink-900{color:var(--color-ink-900)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.decoration-brand-300{-webkit-text-decoration-color:var(--color-brand-300);-webkit-text-decoration-color:var(--color-brand-300);text-decoration-color:var(--color-brand-300)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-action-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-action-red-700)}.peer-checked\/dismiss\:bg-action-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-action-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/instructions\:block:is(:where(.peer\/instructions):checked~*){display:block}.peer-checked\/save\:border-action-green-700:is(:where(.peer\/save):checked~*){border-color:var(--color-action-green-700)}.peer-checked\/save\:bg-action-green-600:is(:where(.peer\/save):checked~*){background-color:var(--color-action-green-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:border-brand-400:hover{border-color:var(--color-brand-400)}.hover\:bg-action-green-100:hover{background-color:var(--color-action-green-100)}.hover\:bg-action-green-600:hover{background-color:var(--color-action-green-600)}.hover\:bg-action-red-50:hover{background-color:var(--color-action-red-50)}.hover\:bg-action-red-100:hover{background-color:var(--color-action-red-100)}.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-200:hover{background-color:var(--color-brand-200)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-white\/70:hover{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/70:hover{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:text-brand-800:hover{color:var(--color-brand-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-400:focus{border-color:var(--color-brand-400)}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-action-green-300:focus{--tw-ring-color:var(--color-action-green-300)}.focus\:ring-brand-300:focus{--tw-ring-color:var(--color-brand-300)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-gray-300:focus{--tw-ring-color:var(--color-gray-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-brand-400:disabled{background-color:var(--color-brand-400)}.disabled\:text-brand-400:disabled{color:var(--color-brand-400)}.disabled\:text-white\/90:disabled{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.disabled\:text-white\/90:disabled{color:color-mix(in oklab,var(--color-white)90%,transparent)}}@media (min-width:40rem){.sm\:w-40{width:calc(var(--spacing)*40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:py-10{padding-block:calc(var(--spacing)*10)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@supports (color:oklch(62% 0.12 220)){:root{--color-ink-500:oklch(55% .032 248);--color-ink-600:oklch(46% .034 248);--color-ink-700:oklch(38.5% .035 250);--color-ink-900:oklch(24% .033 254);--color-action-green-50:oklch(97.6% .02 154);--color-action-green-100:oklch(94.5% .042 154);--color-action-green-300:oklch(82.8% .111 154);--color-action-green-500:oklch(68.5% .169 154);--color-action-green-600:oklch(58.8% .158 154);--color-action-green-700:oklch(50% .134 154);--color-action-red-50:oklch(97.6% .02 28);--color-action-red-100:oklch(94.5% .042 28);--color-action-red-300:oklch(82.8% .111 28);--color-action-red-500:oklch(68.5% .169 28);--color-action-red-600:oklch(58.8% .158 28);--color-action-red-700:oklch(50% .134 28)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-700:oklch(50.5% .213 27.518);--color-amber-500:oklch(76.9% .188 70.08);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-700:oklch(50.8% .118 165.612);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700);--color-brand-800:var(--brand-800);--color-brand-900:var(--brand-900);--color-ink-500:#64748b;--color-ink-600:#475569;--color-ink-700:#334155;--color-ink-900:#0f172a;--color-action-green-50:#ecfdf5;--color-action-green-100:#d1fae5;--color-action-green-300:#6ee7b7;--color-action-green-500:#10b981;--color-action-green-600:#059669;--color-action-green-700:#047857;--color-action-red-50:#fef2f2;--color-action-red-100:#fee2e2;--color-action-red-500:#ef4444;--color-action-red-600:#dc2626;--color-action-red-700:#b91c1c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}p{text-wrap:pretty}}@layer components{.friendly-card{corner-shape:squircle;border-radius:48px}.friendly-card-soft{corner-shape:squircle;border-radius:28px}.shopping-wine-details,.shopping-recipe-card:has(details[open]) .shopping-wine-preview{display:none}.shopping-recipe-card:has(details[open]) .shopping-wine-details{display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-3{top:calc(var(--spacing)*3)}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing)*2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-action-green-500{border-color:var(--color-action-green-500)}.border-action-red-500{border-color:var(--color-action-red-500)}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-action-green-50{background-color:var(--color-action-green-50)}.bg-action-green-500{background-color:var(--color-action-green-500)}.bg-action-red-50{background-color:var(--color-action-red-50)}.bg-brand-50,.bg-brand-50\/40{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/40{background-color:color-mix(in oklab,var(--color-brand-50)40%,transparent)}}.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-50\/70{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/70{background-color:color-mix(in oklab,var(--color-brand-50)70%,transparent)}}.bg-brand-50\/80{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/80{background-color:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.bg-brand-100{background-color:var(--color-brand-100)}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/88{background-color:#ffffffe0}@supports (color:color-mix(in lab, red, red)){.bg-white\/88{background-color:color-mix(in oklab,var(--color-white)88%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-brand-50\/80{--tw-gradient-from:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.from-brand-50\/80{--tw-gradient-from:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.from-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\/72{--tw-gradient-from:#ffffffb8}@supports (color:color-mix(in lab, red, red)){.from-white\/72{--tw-gradient-from:color-mix(in oklab,var(--color-white)72%,transparent)}}.from-white\/72{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-white{--tw-gradient-via:var(--color-white);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-white\/62{--tw-gradient-via:#ffffff9e}@supports (color:color-mix(in lab, red, red)){.via-white\/62{--tw-gradient-via:color-mix(in oklab,var(--color-white)62%,transparent)}}.via-white\/62{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-brand-50\/80{--tw-gradient-to:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.to-brand-50\/80{--tw-gradient-to:color-mix(in oklab,var(--color-brand-50)80%,transparent)}}.to-brand-50\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white\/78{--tw-gradient-to:#ffffffc7}@supports (color:color-mix(in lab, red, red)){.to-white\/78{--tw-gradient-to:color-mix(in oklab,var(--color-white)78%,transparent)}}.to-white\/78{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.whitespace-pre-line{white-space:pre-line}.text-action-green-700{color:var(--color-action-green-700)}.text-action-red-600{color:var(--color-action-red-600)}.text-action-red-700{color:var(--color-action-red-700)}.text-amber-500{color:var(--color-amber-500)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-brand-800{color:var(--color-brand-800)}.text-brand-900{color:var(--color-brand-900)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-ink-500{color:var(--color-ink-500)}.text-ink-600{color:var(--color-ink-600)}.text-ink-700{color:var(--color-ink-700)}.text-ink-900{color:var(--color-ink-900)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.decoration-brand-300{-webkit-text-decoration-color:var(--color-brand-300);-webkit-text-decoration-color:var(--color-brand-300);text-decoration-color:var(--color-brand-300)}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.opacity-60{opacity:.6}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-action-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-action-red-700)}.peer-checked\/dismiss\:bg-action-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-action-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/instructions\:block:is(:where(.peer\/instructions):checked~*){display:block}.peer-checked\/save\:border-action-green-700:is(:where(.peer\/save):checked~*){border-color:var(--color-action-green-700)}.peer-checked\/save\:bg-action-green-600:is(:where(.peer\/save):checked~*){background-color:var(--color-action-green-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:border-brand-400:hover{border-color:var(--color-brand-400)}.hover\:bg-action-green-100:hover{background-color:var(--color-action-green-100)}.hover\:bg-action-green-600:hover{background-color:var(--color-action-green-600)}.hover\:bg-action-red-50:hover{background-color:var(--color-action-red-50)}.hover\:bg-action-red-100:hover{background-color:var(--color-action-red-100)}.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-200:hover{background-color:var(--color-brand-200)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-white\/70:hover{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/70:hover{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:text-brand-800:hover{color:var(--color-brand-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-400:focus{border-color:var(--color-brand-400)}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-action-green-300:focus{--tw-ring-color:var(--color-action-green-300)}.focus\:ring-brand-300:focus{--tw-ring-color:var(--color-brand-300)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-gray-300:focus{--tw-ring-color:var(--color-gray-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-brand-400:disabled{background-color:var(--color-brand-400)}.disabled\:text-brand-400:disabled{color:var(--color-brand-400)}.disabled\:text-white\/90:disabled{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.disabled\:text-white\/90:disabled{color:color-mix(in oklab,var(--color-white)90%,transparent)}}@media (min-width:40rem){.sm\:w-40{width:calc(var(--spacing)*40)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:py-10{padding-block:calc(var(--spacing)*10)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@supports (color:oklch(62% 0.12 220)){:root{--color-ink-500:oklch(55% .032 248);--color-ink-600:oklch(46% .034 248);--color-ink-700:oklch(38.5% .035 250);--color-ink-900:oklch(24% .033 254);--color-action-green-50:oklch(97.6% .02 154);--color-action-green-100:oklch(94.5% .042 154);--color-action-green-300:oklch(82.8% .111 154);--color-action-green-500:oklch(68.5% .169 154);--color-action-green-600:oklch(58.8% .158 154);--color-action-green-700:oklch(50% .134 154);--color-action-red-50:oklch(97.6% .02 28);--color-action-red-100:oklch(94.5% .042 28);--color-action-red-300:oklch(82.8% .111 28);--color-action-red-500:oklch(68.5% .169 28);--color-action-red-600:oklch(58.8% .158 28);--color-action-red-700:oklch(50% .134 28)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}
+20
internal/templates/location_request_store.html
··· 1 + {{define "location_request_store"}} 2 + <form 3 + method="POST" 4 + action="/locations/request-store" 5 + hx-post="/locations/request-store" 6 + hx-swap="outerHTML"> 7 + <input type="hidden" name="store_id" value="{{.ID}}" /> 8 + <button type="submit" 9 + class="rounded-lg border border-brand-300 bg-white px-3 py-1.5 text-sm font-semibold text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 10 + Request this store 11 + </button> 12 + </form> 13 + {{end}} 14 + 15 + {{define "location_request_store_success"}} 16 + <div aria-live="polite" 17 + class="inline-flex items-center justify-center rounded-lg border border-brand-200 bg-brand-50 px-3 py-1.5 text-sm font-semibold text-brand-700"> 18 + Request sent 19 + </div> 20 + {{end}}
+17 -2
internal/templates/locations.html
··· 21 21 22 22 <ul id="locations-list" class="divide-y divide-brand-100"> 23 23 {{range .Locations}} 24 - <li class="px-8 py-6"> 24 + <li class="px-8 {{if .SupportsStaples}}py-6{{else}}py-4{{end}} {{if not .SupportsStaples}}opacity-60{{end}}"> 25 25 <input id="instructions-{{.ID}}" type="checkbox" class="peer/instructions hidden" /> 26 - <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 26 + <div class="flex flex-col {{if .SupportsStaples}}gap-4 sm:flex-row sm:items-center{{else}}gap-2 sm:flex-row sm:items-start{{end}} sm:justify-between"> 27 + {{if .SupportsStaples}} 27 28 <a href="/recipes?location={{.ID}}" 28 29 class="flex flex-1 flex-col gap-1 rounded-xl py-2 transition hover:bg-brand-50 focus:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 29 30 <span class="text-lg font-semibold text-brand-700">{{.Name}}</span> 30 31 <span class="text-sm text-gray-600">{{.Address}}</span> 31 32 </a> 33 + {{else}} 34 + <div class="flex flex-1 flex-col gap-0.5 rounded-xl py-1"> 35 + <span class="text-base font-semibold text-gray-600">{{.Name}}</span> 36 + <span class="text-sm text-gray-500">{{.Address}}</span> 37 + <span class="text-xs text-gray-500">Not available yet for recipe building.</span> 38 + </div> 39 + {{end}} 40 + 32 41 <div class="flex flex-wrap items-center gap-2 sm:justify-end"> 42 + {{if .SupportsStaples}} 33 43 <form 34 44 method="POST" 35 45 action="/user/favorite" ··· 52 62 class="inline-flex cursor-pointer items-center justify-center rounded-lg border border-brand-300 bg-white px-3 py-2 text-sm font-semibold text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 53 63 Chef notes 54 64 </label> 65 + {{else}} 66 + {{template "location_request_store" .}} 67 + {{end}} 55 68 </div> 56 69 </div> 70 + {{if .SupportsStaples}} 57 71 <div class="mt-4 hidden rounded-xl border border-brand-100 bg-brand-50/40 p-4 peer-checked/instructions:block"> 58 72 <form method="GET" action="/recipes" class="flex flex-col gap-3"> 59 73 <input type="hidden" name="location" value="{{.ID}}" /> ··· 67 81 </div> 68 82 </form> 69 83 </div> 84 + {{end}} 70 85 </li> 71 86 {{end}} 72 87 </ul>
+4
internal/walmart/staples.go
··· 40 40 return true 41 41 } 42 42 43 + func (*identityProvider) HasInventory(locationID string) bool { 44 + return false 45 + } 46 + 43 47 func (p identityProvider) Signature() string { 44 48 return UnsupportedStaplesSignature 45 49 }
+4
internal/wholefoods/locations.go
··· 61 61 return ok 62 62 } 63 63 64 + func (*LocationBackend) HasInventory(locationID string) bool { 65 + return true 66 + } 67 + 64 68 func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 65 69 normalized, ok := parseLocationID(locationID) 66 70 if !ok {